MIOT Release Train #37
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: MIOT Release Train | |
| on: | |
| workflow_dispatch: | |
| inputs: | |
| release_version: | |
| description: "Umbrella stack semver without v prefix. Leave blank to bump the latest miot-stack@v* patch." | |
| required: false | |
| type: string | |
| release_notes_version: | |
| description: "Private release-notes semver without v prefix. Leave blank to bump the latest release note patch." | |
| required: false | |
| type: string | |
| permissions: | |
| contents: write | |
| pull-requests: write | |
| packages: write | |
| id-token: write | |
| env: | |
| NODE_VERSION: "24" | |
| GHCR_REGISTRY: ghcr.io | |
| IMAGE_OWNER: microboxlabs | |
| APP_IMAGE_NAME: miot-app | |
| DOCS_IMAGE_NAME: miot-docs | |
| MODULITH_IMAGE_NAME: miot-srv | |
| HARNESS_IMAGE_NAME: miot-harness | |
| JAVA_VERSION: "21" | |
| RELEASE_NOTES_MODEL: auto | |
| RELEASE_NOTES_CONTEXT: long_context | |
| jobs: | |
| prepare: | |
| name: Prepare release | |
| runs-on: ubuntu-latest | |
| outputs: | |
| release_version: ${{ steps.version.outputs.release_version }} | |
| stack_tag: ${{ steps.plan.outputs.stack_tag }} | |
| stack_plan_file: ${{ steps.plan.outputs.stack_plan_file }} | |
| app_changed: ${{ steps.plan.outputs.app_changed }} | |
| app_version: ${{ steps.plan.outputs.app_version }} | |
| app_tag: ${{ steps.plan.outputs.app_tag }} | |
| docs_changed: ${{ steps.plan.outputs.docs_changed }} | |
| docs_version: ${{ steps.plan.outputs.docs_version }} | |
| docs_tag: ${{ steps.plan.outputs.docs_tag }} | |
| modulith_changed: ${{ steps.plan.outputs.modulith_changed }} | |
| modulith_version: ${{ steps.plan.outputs.modulith_version }} | |
| modulith_tag: ${{ steps.plan.outputs.modulith_tag }} | |
| harness_changed: ${{ steps.plan.outputs.harness_changed }} | |
| harness_version: ${{ steps.plan.outputs.harness_version }} | |
| harness_tag: ${{ steps.plan.outputs.harness_tag }} | |
| release_notes_version: ${{ steps.version.outputs.release_notes_version }} | |
| private_milestone: ${{ steps.version.outputs.private_milestone }} | |
| oss_milestone: ${{ steps.version.outputs.oss_milestone }} | |
| branch: ${{ steps.branch.outputs.branch }} | |
| pr_number: ${{ steps.pr.outputs.number }} | |
| pr_url: ${{ steps.pr.outputs.url }} | |
| steps: | |
| - uses: actions/checkout@v5 | |
| with: | |
| fetch-depth: 0 | |
| - uses: actions/setup-node@v6 | |
| with: | |
| node-version: ${{ env.NODE_VERSION }} | |
| - uses: actions/setup-java@v5 | |
| with: | |
| distribution: temurin | |
| java-version: ${{ env.JAVA_VERSION }} | |
| cache: maven | |
| cache-dependency-path: quarkus-srv/pom.xml | |
| - name: Determine release metadata | |
| id: version | |
| run: | | |
| if [[ -n "${{ inputs.release_version }}" ]]; then | |
| version="${{ inputs.release_version }}" | |
| else | |
| latest="$(git tag --list 'miot-stack@v*' | sed 's/^miot-stack@v//' | sort -V | tail -1)" | |
| if [[ -z "$latest" ]]; then | |
| latest="$( | |
| { | |
| git tag --list 'app@v*' | sed 's/^app@v//' | |
| git tag --list 'docs@v*' | sed 's/^docs@v//' | |
| git tag --list 'modulith@v*' | sed 's/^modulith@v//' | |
| git tag --list 'harness@v*' | sed 's/^harness@v//' | |
| } | sort -V | tail -1 | |
| )" | |
| fi | |
| if [[ -z "$latest" ]]; then | |
| version="0.1.0" | |
| else | |
| IFS=. read -r major minor patch <<< "$latest" | |
| version="${major}.${minor}.$((patch + 1))" | |
| fi | |
| fi | |
| if ! [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then | |
| echo "Invalid release version: $version" >&2 | |
| exit 1 | |
| fi | |
| if [[ -n "${{ inputs.release_notes_version }}" ]]; then | |
| release_notes_version="${{ inputs.release_notes_version }}" | |
| else | |
| note_dirs=(turbo-repo/apps/app/src/releases) | |
| if [[ -d releases/notes ]]; then | |
| note_dirs+=(releases/notes) | |
| fi | |
| latest_release_notes="$(find "${note_dirs[@]}" \ | |
| -maxdepth 1 \ | |
| -type f \ | |
| -name 'v[0-9]*.[0-9]*.[0-9]*.mdx' \ | |
| -exec basename {} .mdx \; | | |
| sed 's/^v//' | | |
| sort -V | | |
| tail -1)" | |
| if [[ -z "$latest_release_notes" ]]; then | |
| release_notes_version="1.0.0" | |
| else | |
| IFS=. read -r notes_major notes_minor notes_patch <<< "$latest_release_notes" | |
| release_notes_version="${notes_major}.${notes_minor}.$((notes_patch + 1))" | |
| fi | |
| fi | |
| if ! [[ "$release_notes_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then | |
| echo "Invalid release notes version: $release_notes_version" >&2 | |
| exit 1 | |
| fi | |
| { | |
| echo "release_version=$version" | |
| echo "release_notes_version=$release_notes_version" | |
| echo "private_milestone=Release $release_notes_version" | |
| echo "oss_milestone=MIOT-$version" | |
| } >> "$GITHUB_OUTPUT" | |
| - name: Create release branch | |
| id: branch | |
| run: | | |
| branch="release/miot-v${{ steps.version.outputs.release_version }}" | |
| if git ls-remote --exit-code --heads origin "$branch" >/dev/null; then | |
| git fetch origin "$branch" | |
| git switch -C "$branch" "origin/$branch" | |
| else | |
| git switch -c "$branch" | |
| fi | |
| echo "branch=$branch" >> "$GITHUB_OUTPUT" | |
| - name: Restore release automation from workflow ref | |
| run: | | |
| git checkout "$GITHUB_SHA" -- \ | |
| .github/scripts/resolve-stack-release-plan.js \ | |
| .github/prompts/app-release-notes.prompt.yml \ | |
| turbo-repo/generative_ai/tools/sh/fetch-release-issues.sh \ | |
| turbo-repo/generative_ai/tools/sh/bump-app-version.sh | |
| - name: Collect release issues | |
| env: | |
| GH_TOKEN: ${{ secrets.RELEASE_BOT_TOKEN }} | |
| GH_PROJECT_OWNER: microboxlabs | |
| run: | | |
| ./turbo-repo/generative_ai/tools/sh/fetch-release-issues.sh \ | |
| --release "${{ steps.version.outputs.private_milestone }}" \ | |
| --oss "${{ steps.version.outputs.oss_milestone }}" \ | |
| --state all > release-issues.json | |
| - name: Resolve component release plan | |
| id: plan | |
| env: | |
| GH_TOKEN: ${{ secrets.RELEASE_BOT_TOKEN }} | |
| STACK_VERSION: ${{ steps.version.outputs.release_version }} | |
| OSS_MILESTONE: ${{ steps.version.outputs.oss_milestone }} | |
| run: node .github/scripts/resolve-stack-release-plan.js | |
| - name: Bump app package version | |
| if: steps.plan.outputs.app_changed == 'true' | |
| run: bash turbo-repo/generative_ai/tools/sh/bump-app-version.sh "${{ steps.plan.outputs.app_version }}" | |
| - name: Bump modulith Maven version | |
| if: steps.plan.outputs.modulith_changed == 'true' | |
| run: ./mvnw versions:set -DnewVersion="${{ steps.plan.outputs.modulith_version }}" -DgenerateBackupPoms=false -DprocessAllModules=true | |
| working-directory: quarkus-srv | |
| - name: Bump harness package version | |
| if: steps.plan.outputs.harness_changed == 'true' | |
| run: | | |
| node - <<'NODE' | |
| const fs = require("fs"); | |
| const path = "miot-harness/pyproject.toml"; | |
| const version = "${{ steps.plan.outputs.harness_version }}"; | |
| const content = fs.readFileSync(path, "utf8"); | |
| const match = content.match(/^version = "([^"]+)"$/m); | |
| if (!match) { | |
| throw new Error(`Could not update project.version in ${path}`); | |
| } | |
| if (match[1] !== version) { | |
| fs.writeFileSync(path, content.replace(/^version = ".*"$/m, `version = "${version}"`)); | |
| } | |
| NODE | |
| - name: Resolve release date | |
| id: release_date | |
| run: echo "date=$(date +'%d/%m/%Y')" >> "$GITHUB_OUTPUT" | |
| - name: Install Copilot CLI | |
| run: npm install -g @github/copilot | |
| - name: Build release notes prompt | |
| run: | | |
| node - <<'NODE' | |
| const fs = require("fs"); | |
| const prompt = fs.readFileSync(".github/prompts/app-release-notes.prompt.yml", "utf8"); | |
| const issues = fs.readFileSync("release-issues.json", "utf8"); | |
| const indentedIssues = issues.split("\n").join("\n "); | |
| const body = prompt | |
| .replaceAll("{{release_version}}", "${{ steps.version.outputs.release_notes_version }}") | |
| .replaceAll("{{release_date}}", "${{ steps.release_date.outputs.date }}") | |
| .replaceAll("{{stack_version}}", "${{ steps.version.outputs.release_version }}") | |
| .replaceAll("{{private_milestone}}", "${{ steps.version.outputs.private_milestone }}") | |
| .replaceAll("{{oss_milestone}}", "${{ steps.version.outputs.oss_milestone }}") | |
| .replaceAll("{{stack_tag}}", "${{ steps.plan.outputs.stack_tag }}") | |
| .replaceAll("{{app_tag}}", "${{ steps.plan.outputs.app_tag }}") | |
| .replaceAll("{{app_changed}}", "${{ steps.plan.outputs.app_changed }}") | |
| .replaceAll("{{docs_tag}}", "${{ steps.plan.outputs.docs_tag }}") | |
| .replaceAll("{{docs_changed}}", "${{ steps.plan.outputs.docs_changed }}") | |
| .replaceAll("{{modulith_tag}}", "${{ steps.plan.outputs.modulith_tag }}") | |
| .replaceAll("{{modulith_changed}}", "${{ steps.plan.outputs.modulith_changed }}") | |
| .replaceAll("{{harness_tag}}", "${{ steps.plan.outputs.harness_tag }}") | |
| .replaceAll("{{harness_changed}}", "${{ steps.plan.outputs.harness_changed }}") | |
| .replaceAll("{{release_issues}}", indentedIssues); | |
| fs.writeFileSync("release-notes.prompt.yml", body); | |
| NODE | |
| - name: Generate release notes with Copilot CLI | |
| id: release_notes_ai | |
| env: | |
| COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_PAT }} | |
| run: | | |
| copilot --version | |
| ruby -ryaml -e ' | |
| prompt = YAML.load_file("release-notes.prompt.yml") | |
| text = prompt.fetch("messages").map do |message| | |
| "#{message.fetch("role").upcase}:\n#{message.fetch("content")}" | |
| end.join("\n\n") | |
| File.write("release-notes.prompt.txt", text) | |
| ' | |
| copilot \ | |
| --prompt "$(cat release-notes.prompt.txt)" \ | |
| --silent \ | |
| --no-ask-user \ | |
| --model "${{ env.RELEASE_NOTES_MODEL }}" \ | |
| --context "${{ env.RELEASE_NOTES_CONTEXT }}" \ | |
| > release-notes.response.mdx | |
| echo "response-file=release-notes.response.mdx" >> "$GITHUB_OUTPUT" | |
| - name: Write release notes | |
| run: | | |
| release_file="releases/notes/v${{ steps.version.outputs.release_notes_version }}.mdx" | |
| app_release_file="turbo-repo/apps/app/src/releases/v${{ steps.version.outputs.release_notes_version }}.mdx" | |
| mkdir -p "$(dirname "$release_file")" | |
| mkdir -p "$(dirname "$app_release_file")" | |
| node - "${{ steps.release_notes_ai.outputs['response-file'] }}" "$release_file" <<'NODE' | |
| const fs = require("fs"); | |
| const [source, target] = process.argv.slice(2); | |
| let content = fs.readFileSync(source, "utf8").trim(); | |
| content = content.replace(/^```(?:mdx|markdown|md)?\s*/i, "").replace(/\s*```$/i, "").trim(); | |
| if (!content.startsWith("# 🔔 Release Notes")) { | |
| throw new Error("AI response did not start with the expected release-notes heading."); | |
| } | |
| fs.writeFileSync(target, `${content}\n`); | |
| NODE | |
| cp "$release_file" "$app_release_file" | |
| - name: Commit release prep | |
| run: | | |
| git config user.name "github-actions[bot]" | |
| git config user.email "41898282+github-actions[bot]@users.noreply.github.com" | |
| git add "${{ steps.plan.outputs.stack_plan_file }}" releases/notes/v${{ steps.version.outputs.release_notes_version }}.mdx turbo-repo/apps/app/src/releases/v${{ steps.version.outputs.release_notes_version }}.mdx turbo-repo/apps/app/package.json turbo-repo/package-lock.json quarkus-srv miot-harness/pyproject.toml | |
| if git diff --cached --quiet; then | |
| echo "No release prep changes to commit." | |
| else | |
| git commit -m "chore(release): prepare MIOT stack v${{ steps.version.outputs.release_version }}" | |
| git push origin "${{ steps.branch.outputs.branch }}" | |
| fi | |
| - name: Open release PR | |
| id: pr | |
| env: | |
| GH_TOKEN: ${{ secrets.RELEASE_BOT_TOKEN }} | |
| run: | | |
| cat > release-pr-body.md <<'EOF' | |
| Prepares ${{ steps.plan.outputs.stack_tag }} from milestone ${{ steps.version.outputs.oss_milestone }}, with release notes v${{ steps.version.outputs.release_notes_version }}. | |
| Components: | |
| - App: ${{ steps.plan.outputs.app_tag }} (changed: ${{ steps.plan.outputs.app_changed }}) | |
| - Docs: ${{ steps.plan.outputs.docs_tag }} (changed: ${{ steps.plan.outputs.docs_changed }}) | |
| - Modulith: ${{ steps.plan.outputs.modulith_tag }} (changed: ${{ steps.plan.outputs.modulith_changed }}) | |
| - Harness: ${{ steps.plan.outputs.harness_tag }} (changed: ${{ steps.plan.outputs.harness_changed }}) | |
| Milestones: ${{ steps.version.outputs.private_milestone }}, ${{ steps.version.outputs.oss_milestone }}. | |
| Approve and merge this PR, then approve the protected release job to tag changed components, resolve component images, and deploy the stack manifest. | |
| EOF | |
| url="$(gh pr list \ | |
| --base trunk \ | |
| --head "${{ steps.branch.outputs.branch }}" \ | |
| --state open \ | |
| --json url \ | |
| --jq '.[0].url // ""')" | |
| if [[ -z "$url" ]]; then | |
| url="$(gh pr create \ | |
| --base trunk \ | |
| --head "${{ steps.branch.outputs.branch }}" \ | |
| --title "chore(release): prepare MIOT stack v${{ steps.version.outputs.release_version }}" \ | |
| --body-file release-pr-body.md)" | |
| fi | |
| number="${url##*/}" | |
| echo "number=$number" >> "$GITHUB_OUTPUT" | |
| echo "url=$url" >> "$GITHUB_OUTPUT" | |
| approve: | |
| name: Approval gate | |
| runs-on: ubuntu-latest | |
| needs: prepare | |
| environment: | |
| name: app-release-approval | |
| url: ${{ needs.prepare.outputs.pr_url }} | |
| steps: | |
| - name: Approval acknowledged | |
| run: echo "Release approval granted for MIOT v${{ needs.prepare.outputs.release_version }}" | |
| tag: | |
| name: Tag trunk | |
| runs-on: ubuntu-latest | |
| needs: [prepare, approve] | |
| outputs: | |
| sha: ${{ steps.release_pr.outputs.sha }} | |
| short_sha: ${{ steps.release_pr.outputs.short_sha }} | |
| steps: | |
| - name: Wait for release PR merge | |
| id: release_pr | |
| env: | |
| GH_TOKEN: ${{ secrets.RELEASE_BOT_TOKEN }} | |
| PR_NUMBER: ${{ needs.prepare.outputs.pr_number }} | |
| run: | | |
| for attempt in {1..60}; do | |
| pr="$(gh pr view "$PR_NUMBER" --repo "${{ github.repository }}" --json mergedAt,mergeCommit,state)" | |
| merged_at="$(echo "$pr" | jq -r '.mergedAt')" | |
| merge_sha="$(echo "$pr" | jq -r '.mergeCommit.oid // ""')" | |
| state="$(echo "$pr" | jq -r '.state')" | |
| if [[ "$state" == "CLOSED" && ( "$merged_at" == "null" || -z "$merge_sha" ) ]]; then | |
| echo "::error::Release PR #$PR_NUMBER was closed without being merged. Re-run the release train to create a new release PR." | |
| exit 1 | |
| fi | |
| if [[ "$merged_at" != "null" && -n "$merge_sha" ]]; then | |
| echo "Release PR #$PR_NUMBER merged at $merged_at ($merge_sha)." | |
| echo "sha=$merge_sha" >> "$GITHUB_OUTPUT" | |
| echo "short_sha=${merge_sha::7}" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| echo "Release PR #$PR_NUMBER is not merged yet. Waiting before tagging... ($attempt/60)" | |
| sleep 60 | |
| done | |
| echo "::error::Release PR #$PR_NUMBER was approved but is still not merged. Merge it before the release train can tag trunk." | |
| exit 1 | |
| - uses: actions/checkout@v5 | |
| with: | |
| ref: ${{ steps.release_pr.outputs.sha }} | |
| fetch-depth: 0 | |
| - uses: actions/setup-java@v5 | |
| with: | |
| distribution: temurin | |
| java-version: ${{ env.JAVA_VERSION }} | |
| cache: maven | |
| cache-dependency-path: quarkus-srv/pom.xml | |
| - name: Verify release prep landed on trunk | |
| run: | | |
| release_file="releases/notes/v${{ needs.prepare.outputs.release_notes_version }}.mdx" | |
| app_release_file="turbo-repo/apps/app/src/releases/v${{ needs.prepare.outputs.release_notes_version }}.mdx" | |
| plan_file="${{ needs.prepare.outputs.stack_plan_file }}" | |
| if [[ "${{ needs.prepare.outputs.app_changed }}" == "true" ]]; then | |
| package_version="$(node -p "require('./turbo-repo/apps/app/package.json').version")" | |
| lock_version="$(node -p "require('./turbo-repo/package-lock.json').packages['apps/app'].version")" | |
| if [[ "$package_version" != "${{ needs.prepare.outputs.app_version }}" ]]; then | |
| echo "trunk package.json is $package_version, expected ${{ needs.prepare.outputs.app_version }}." >&2 | |
| echo "Merge the release PR before approving/tagging." >&2 | |
| exit 1 | |
| fi | |
| if [[ "$lock_version" != "${{ needs.prepare.outputs.app_version }}" ]]; then | |
| echo "trunk package-lock.json is $lock_version, expected ${{ needs.prepare.outputs.app_version }}." >&2 | |
| echo "Merge the release PR before approving/tagging." >&2 | |
| exit 1 | |
| fi | |
| fi | |
| if [[ "${{ needs.prepare.outputs.modulith_changed }}" == "true" ]]; then | |
| modulith_version="$(cd quarkus-srv && ./mvnw help:evaluate -Dexpression=project.version -q -DforceStdout)" | |
| if [[ "$modulith_version" != "${{ needs.prepare.outputs.modulith_version }}" ]]; then | |
| echo "trunk modulith version is $modulith_version, expected ${{ needs.prepare.outputs.modulith_version }}." >&2 | |
| echo "Merge the release PR before approving/tagging." >&2 | |
| exit 1 | |
| fi | |
| fi | |
| if [[ "${{ needs.prepare.outputs.harness_changed }}" == "true" ]]; then | |
| harness_version="$(node -p "require('fs').readFileSync('miot-harness/pyproject.toml', 'utf8').match(/^version = \\\"([^\\\"]+)\\\"$/m)?.[1] || process.exit(1)")" | |
| if [[ "$harness_version" != "${{ needs.prepare.outputs.harness_version }}" ]]; then | |
| echo "trunk harness version is $harness_version, expected ${{ needs.prepare.outputs.harness_version }}." >&2 | |
| echo "Merge the release PR before approving/tagging." >&2 | |
| exit 1 | |
| fi | |
| fi | |
| if [[ ! -f "$release_file" ]]; then | |
| echo "Missing release notes file on trunk: $release_file" >&2 | |
| echo "Merge the release PR before approving/tagging." >&2 | |
| exit 1 | |
| fi | |
| if [[ ! -f "$app_release_file" ]]; then | |
| echo "Missing app release notes file on trunk: $app_release_file" >&2 | |
| echo "Merge the release PR before approving/tagging." >&2 | |
| exit 1 | |
| fi | |
| if [[ ! -f "$plan_file" ]]; then | |
| echo "Missing stack release plan on trunk: $plan_file" >&2 | |
| echo "Merge the release PR before approving/tagging." >&2 | |
| exit 1 | |
| fi | |
| - name: Create and push component tags | |
| run: | | |
| git config user.name "github-actions[bot]" | |
| git config user.email "41898282+github-actions[bot]@users.noreply.github.com" | |
| tags=("${{ needs.prepare.outputs.stack_tag }}") | |
| git tag -a "${{ needs.prepare.outputs.stack_tag }}" -m "${{ needs.prepare.outputs.stack_tag }}" | |
| if [[ "${{ needs.prepare.outputs.app_changed }}" == "true" ]]; then | |
| git tag -a "${{ needs.prepare.outputs.app_tag }}" -m "${{ needs.prepare.outputs.app_tag }}" | |
| tags+=("${{ needs.prepare.outputs.app_tag }}") | |
| fi | |
| if [[ "${{ needs.prepare.outputs.docs_changed }}" == "true" ]]; then | |
| git tag -a "${{ needs.prepare.outputs.docs_tag }}" -m "${{ needs.prepare.outputs.docs_tag }}" | |
| tags+=("${{ needs.prepare.outputs.docs_tag }}") | |
| fi | |
| if [[ "${{ needs.prepare.outputs.modulith_changed }}" == "true" ]]; then | |
| git tag -a "${{ needs.prepare.outputs.modulith_tag }}" -m "${{ needs.prepare.outputs.modulith_tag }}" | |
| tags+=("${{ needs.prepare.outputs.modulith_tag }}") | |
| fi | |
| if [[ "${{ needs.prepare.outputs.harness_changed }}" == "true" ]]; then | |
| git tag -a "${{ needs.prepare.outputs.harness_tag }}" -m "${{ needs.prepare.outputs.harness_tag }}" | |
| tags+=("${{ needs.prepare.outputs.harness_tag }}") | |
| fi | |
| git push origin "${tags[@]}" | |
| - name: Create GitHub releases | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| run: | | |
| gh release create "${{ needs.prepare.outputs.stack_tag }}" \ | |
| --title "${{ needs.prepare.outputs.stack_tag }}" \ | |
| --generate-notes \ | |
| --latest | |
| if [[ "${{ needs.prepare.outputs.app_changed }}" == "true" ]]; then | |
| gh release create "${{ needs.prepare.outputs.app_tag }}" \ | |
| --title "${{ needs.prepare.outputs.app_tag }}" \ | |
| --generate-notes | |
| fi | |
| if [[ "${{ needs.prepare.outputs.docs_changed }}" == "true" ]]; then | |
| gh release create "${{ needs.prepare.outputs.docs_tag }}" \ | |
| --title "${{ needs.prepare.outputs.docs_tag }}" \ | |
| --generate-notes | |
| fi | |
| if [[ "${{ needs.prepare.outputs.modulith_changed }}" == "true" ]]; then | |
| gh release create "${{ needs.prepare.outputs.modulith_tag }}" \ | |
| --title "${{ needs.prepare.outputs.modulith_tag }}" \ | |
| --generate-notes | |
| fi | |
| if [[ "${{ needs.prepare.outputs.harness_changed }}" == "true" ]]; then | |
| gh release create "${{ needs.prepare.outputs.harness_tag }}" \ | |
| --title "${{ needs.prepare.outputs.harness_tag }}" \ | |
| --generate-notes | |
| fi | |
| build-app: | |
| name: Resolve app image | |
| runs-on: ubuntu-latest | |
| needs: [prepare, tag] | |
| outputs: | |
| image: ${{ steps.image.outputs.image }} | |
| steps: | |
| - uses: actions/checkout@v5 | |
| if: needs.prepare.outputs.app_changed == 'true' | |
| with: | |
| ref: ${{ needs.tag.outputs.sha }} | |
| fetch-depth: 0 | |
| - uses: actions/setup-node@v6 | |
| if: needs.prepare.outputs.app_changed == 'true' | |
| with: | |
| node-version: ${{ env.NODE_VERSION }} | |
| cache: npm | |
| cache-dependency-path: turbo-repo/package-lock.json | |
| - name: Resolve build credits | |
| id: credits | |
| if: needs.prepare.outputs.app_changed == 'true' | |
| env: | |
| GIT_SHA: ${{ needs.tag.outputs.sha }} | |
| STACK_TAG: ${{ needs.prepare.outputs.stack_tag }} | |
| GITHUB_TOKEN: ${{ github.token }} | |
| run: | | |
| previous_stack_tag="$( | |
| git tag --list 'miot-stack@v*' --sort=-v:refname | | |
| grep -vx "$STACK_TAG" | | |
| head -1 || true | |
| )" | |
| range="$GIT_SHA" | |
| if [[ -n "$previous_stack_tag" ]]; then | |
| range="${previous_stack_tag}..${GIT_SHA}" | |
| fi | |
| credit_log="$(git log --format='%H%x09%aN%x09%aE' --numstat "$range")" | |
| if [[ -z "$credit_log" ]]; then | |
| credit_log="$(git log -1 --format='%H%x09%aN%x09%aE' --numstat "$GIT_SHA")" | |
| fi | |
| credits_json="$( | |
| GIT_CREDIT_LOG="$credit_log" GITHUB_TOKEN="$GITHUB_TOKEN" node <<'NODE' | |
| const crypto = require("crypto"); | |
| const lines = (process.env.GIT_CREDIT_LOG ?? "").split("\n"); | |
| const contributors = new Map(); | |
| let currentKey = null; | |
| function githubUsername(email) { | |
| return email?.match(/^(?:\d+\+)?([^@]+)@users\.noreply\.github\.com$/)?.[1]; | |
| } | |
| function avatarUrl(email, username) { | |
| if (username) { | |
| return `https://github.com/${username}.png?size=96`; | |
| } | |
| if (!email) { | |
| return undefined; | |
| } | |
| const hash = crypto.createHash("md5").update(email.trim().toLowerCase()).digest("hex"); | |
| return `https://www.gravatar.com/avatar/${hash}?d=identicon&s=96`; | |
| } | |
| async function githubProfile(username) { | |
| if (!username) { | |
| return {}; | |
| } | |
| const response = await fetch(`https://api.github.com/users/${username}`, { | |
| headers: { | |
| Accept: "application/vnd.github+json", | |
| Authorization: `Bearer ${process.env.GITHUB_TOKEN}`, | |
| "X-GitHub-Api-Version": "2022-11-28", | |
| }, | |
| }); | |
| if (!response.ok) { | |
| return {}; | |
| } | |
| const profile = await response.json(); | |
| return { | |
| profileName: profile.name || undefined, | |
| bio: profile.bio || undefined, | |
| company: profile.company || undefined, | |
| location: profile.location || undefined, | |
| followers: Number.isFinite(profile.followers) ? profile.followers : undefined, | |
| publicRepos: Number.isFinite(profile.public_repos) ? profile.public_repos : undefined, | |
| }; | |
| } | |
| for (const line of lines) { | |
| if (!line.trim()) { | |
| continue; | |
| } | |
| const parts = line.split("\t"); | |
| if (parts.length === 3 && /^[0-9a-f]{40}$/.test(parts[0])) { | |
| const [, name, email] = parts; | |
| const username = githubUsername(email); | |
| currentKey = (email || username || name).toLowerCase(); | |
| if (!contributors.has(currentKey)) { | |
| contributors.set(currentKey, { | |
| name, | |
| ...(email ? { email } : {}), | |
| ...(username ? { username, url: `https://github.com/${username}` } : {}), | |
| avatarUrl: avatarUrl(email, username), | |
| commitCount: 0, | |
| filesChanged: 0, | |
| additions: 0, | |
| deletions: 0, | |
| }); | |
| } | |
| contributors.get(currentKey).commitCount += 1; | |
| continue; | |
| } | |
| if (!currentKey || parts.length < 3) { | |
| continue; | |
| } | |
| const [added, deleted] = parts; | |
| const credit = contributors.get(currentKey); | |
| credit.filesChanged += 1; | |
| credit.additions += /^\d+$/.test(added) ? Number(added) : 0; | |
| credit.deletions += /^\d+$/.test(deleted) ? Number(deleted) : 0; | |
| } | |
| const allCredits = [...contributors.values()].map((credit) => ({ | |
| ...credit, | |
| impactScore: credit.commitCount * 25 + credit.filesChanged * 3 + Math.ceil((credit.additions + credit.deletions) / 50), | |
| })); | |
| Promise.all( | |
| allCredits.map(async (credit) => ({ | |
| ...credit, | |
| ...(await githubProfile(credit.username)), | |
| })) | |
| ) | |
| .then((enrichedCredits) => { | |
| const humanCredits = enrichedCredits.filter( | |
| (credit) => !credit.name.endsWith("[bot]") && credit.username !== "github-actions" | |
| ); | |
| const credits = (humanCredits.length > 0 ? humanCredits : enrichedCredits) | |
| .sort((left, right) => right.commitCount - left.commitCount || right.impactScore - left.impactScore || left.name.localeCompare(right.name)) | |
| .map((credit, index) => ({ | |
| ...credit, | |
| rank: index + 1, | |
| role: index === 0 ? "Top contributor" : "Contributor", | |
| })); | |
| console.log(JSON.stringify(credits)); | |
| }) | |
| .catch((error) => { | |
| console.error(error); | |
| process.exit(1); | |
| }); | |
| NODE | |
| )" | |
| { | |
| echo "json<<EOF" | |
| echo "$credits_json" | |
| echo "EOF" | |
| } >> "$GITHUB_OUTPUT" | |
| - name: Install dependencies | |
| if: needs.prepare.outputs.app_changed == 'true' | |
| run: npm ci | |
| working-directory: turbo-repo | |
| - name: Write packaged build info | |
| if: needs.prepare.outputs.app_changed == 'true' | |
| env: | |
| RELEASE_VERSION: ${{ needs.prepare.outputs.release_version }} | |
| RELEASE_NOTES_VERSION: v${{ needs.prepare.outputs.release_notes_version }} | |
| STACK_TAG: ${{ needs.prepare.outputs.stack_tag }} | |
| GIT_SHA: ${{ needs.tag.outputs.sha }} | |
| SHORT_SHA: ${{ needs.tag.outputs.short_sha }} | |
| WORKFLOW_RUN_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} | |
| APP_VERSION: ${{ needs.prepare.outputs.app_version }} | |
| APP_TAG: ${{ needs.prepare.outputs.app_tag }} | |
| APP_IMAGE_REPOSITORY: ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_OWNER }}/${{ env.APP_IMAGE_NAME }} | |
| APP_IMAGE_TAG: app-v${{ needs.prepare.outputs.app_version }} | |
| DOCS_VERSION: ${{ needs.prepare.outputs.docs_version }} | |
| DOCS_TAG: ${{ needs.prepare.outputs.docs_tag }} | |
| DOCS_IMAGE_REPOSITORY: ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_OWNER }}/${{ env.DOCS_IMAGE_NAME }} | |
| DOCS_IMAGE_TAG: docs-v${{ needs.prepare.outputs.docs_version }} | |
| MODULITH_VERSION: ${{ needs.prepare.outputs.modulith_version }} | |
| MODULITH_TAG: ${{ needs.prepare.outputs.modulith_tag }} | |
| MODULITH_IMAGE_REPOSITORY: ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_OWNER }}/${{ env.MODULITH_IMAGE_NAME }} | |
| MODULITH_IMAGE_TAG: modulith-v${{ needs.prepare.outputs.modulith_version }} | |
| HARNESS_VERSION: ${{ needs.prepare.outputs.harness_version }} | |
| HARNESS_TAG: ${{ needs.prepare.outputs.harness_tag }} | |
| HARNESS_IMAGE_REPOSITORY: ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_OWNER }}/${{ env.HARNESS_IMAGE_NAME }} | |
| HARNESS_IMAGE_TAG: harness-v${{ needs.prepare.outputs.harness_version }} | |
| BUILD_CREDITS_JSON: ${{ steps.credits.outputs.json }} | |
| run: | | |
| mkdir -p turbo-repo/apps/app/public | |
| jq -n \ | |
| --arg release_version "$RELEASE_VERSION" \ | |
| --arg release_notes_version "$RELEASE_NOTES_VERSION" \ | |
| --arg stack_tag "$STACK_TAG" \ | |
| --arg git_sha "$GIT_SHA" \ | |
| --arg short_sha "$SHORT_SHA" \ | |
| --arg built_at "$(date -u +'%Y-%m-%dT%H:%M:%SZ')" \ | |
| --arg workflow_run_url "$WORKFLOW_RUN_URL" \ | |
| --arg app_version "$APP_VERSION" \ | |
| --arg app_tag "$APP_TAG" \ | |
| --arg app_image_repository "$APP_IMAGE_REPOSITORY" \ | |
| --arg app_image_tag "$APP_IMAGE_TAG" \ | |
| --arg docs_version "$DOCS_VERSION" \ | |
| --arg docs_tag "$DOCS_TAG" \ | |
| --arg docs_image_repository "$DOCS_IMAGE_REPOSITORY" \ | |
| --arg docs_image_tag "$DOCS_IMAGE_TAG" \ | |
| --arg modulith_version "$MODULITH_VERSION" \ | |
| --arg modulith_tag "$MODULITH_TAG" \ | |
| --arg modulith_image_repository "$MODULITH_IMAGE_REPOSITORY" \ | |
| --arg modulith_image_tag "$MODULITH_IMAGE_TAG" \ | |
| --arg harness_version "$HARNESS_VERSION" \ | |
| --arg harness_tag "$HARNESS_TAG" \ | |
| --arg harness_image_repository "$HARNESS_IMAGE_REPOSITORY" \ | |
| --arg harness_image_tag "$HARNESS_IMAGE_TAG" \ | |
| --argjson credits "$BUILD_CREDITS_JSON" \ | |
| '{ | |
| product: "ModularIoT", | |
| release_version: $release_version, | |
| release_notes_version: $release_notes_version, | |
| channel: "release", | |
| stack_tag: $stack_tag, | |
| git_sha: $git_sha, | |
| short_sha: $short_sha, | |
| built_at: $built_at, | |
| workflow_run_url: $workflow_run_url, | |
| manifest_version: 1, | |
| credits: $credits, | |
| components: { | |
| app: { | |
| version: $app_version, | |
| tag: $app_tag, | |
| image_repository: $app_image_repository, | |
| image_tag: $app_image_tag | |
| }, | |
| docs: { | |
| version: $docs_version, | |
| tag: $docs_tag, | |
| image_repository: $docs_image_repository, | |
| image_tag: $docs_image_tag | |
| }, | |
| modulith: { | |
| version: $modulith_version, | |
| tag: $modulith_tag, | |
| image_repository: $modulith_image_repository, | |
| image_tag: $modulith_image_tag | |
| }, | |
| harness: { | |
| version: $harness_version, | |
| tag: $harness_tag, | |
| image_repository: $harness_image_repository, | |
| image_tag: $harness_image_tag | |
| } | |
| } | |
| }' > turbo-repo/apps/app/public/build-info.json | |
| - name: Build app | |
| if: needs.prepare.outputs.app_changed == 'true' | |
| run: npx turbo run build --filter=@modulariot/app | |
| working-directory: turbo-repo | |
| - name: Stage app artifact | |
| if: needs.prepare.outputs.app_changed == 'true' | |
| run: | | |
| mkdir -p build-context/.next | |
| cp -r turbo-repo/apps/app/.next/standalone build-context/.next/standalone | |
| cp -r turbo-repo/apps/app/.next/static build-context/.next/static | |
| cp -r turbo-repo/apps/app/public build-context/public | |
| cp turbo-repo/docker/nextjs.standalone.Dockerfile build-context/ | |
| - uses: docker/setup-buildx-action@v3 | |
| - uses: docker/login-action@v3 | |
| with: | |
| registry: ${{ env.GHCR_REGISTRY }} | |
| username: ${{ github.actor }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Build and push image | |
| if: needs.prepare.outputs.app_changed == 'true' | |
| id: docker | |
| uses: docker/build-push-action@v6 | |
| with: | |
| context: build-context/ | |
| file: build-context/nextjs.standalone.Dockerfile | |
| platforms: linux/amd64 | |
| push: true | |
| tags: | | |
| ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_OWNER }}/${{ env.APP_IMAGE_NAME }}:${{ needs.prepare.outputs.app_version }} | |
| ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_OWNER }}/${{ env.APP_IMAGE_NAME }}:app-v${{ needs.prepare.outputs.app_version }} | |
| ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_OWNER }}/${{ env.APP_IMAGE_NAME }}:sha-${{ needs.tag.outputs.short_sha }} | |
| build-args: | | |
| APP_NAME=app | |
| provenance: true | |
| sbom: true | |
| - name: Export image ref | |
| id: image | |
| run: | | |
| if [[ "${{ needs.prepare.outputs.app_changed }}" == "true" ]]; then | |
| digest="${{ steps.docker.outputs.digest }}" | |
| else | |
| digest="$(docker buildx imagetools inspect "${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_OWNER }}/${{ env.APP_IMAGE_NAME }}:app-v${{ needs.prepare.outputs.app_version }}" | awk '/^Digest:/ { print $2; exit }')" | |
| fi | |
| echo "image=${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_OWNER }}/${{ env.APP_IMAGE_NAME }}@${digest}" >> "$GITHUB_OUTPUT" | |
| build-docs: | |
| name: Resolve docs image | |
| runs-on: ubuntu-latest | |
| needs: [prepare, tag] | |
| outputs: | |
| image: ${{ steps.image.outputs.image }} | |
| steps: | |
| - uses: actions/checkout@v5 | |
| if: needs.prepare.outputs.docs_changed == 'true' | |
| with: | |
| ref: ${{ needs.tag.outputs.sha }} | |
| fetch-depth: 0 | |
| - uses: actions/setup-node@v6 | |
| if: needs.prepare.outputs.docs_changed == 'true' | |
| with: | |
| node-version: ${{ env.NODE_VERSION }} | |
| cache: npm | |
| cache-dependency-path: turbo-repo/package-lock.json | |
| - name: Install dependencies | |
| if: needs.prepare.outputs.docs_changed == 'true' | |
| run: npm ci | |
| working-directory: turbo-repo | |
| - name: Build docs | |
| if: needs.prepare.outputs.docs_changed == 'true' | |
| run: npx turbo run build --filter=@modulariot/docs | |
| working-directory: turbo-repo | |
| - name: Stage docs artifact | |
| if: needs.prepare.outputs.docs_changed == 'true' | |
| run: | | |
| mkdir -p build-context/.next | |
| cp -r turbo-repo/apps/docs/.next/standalone build-context/.next/standalone | |
| cp -r turbo-repo/apps/docs/.next/static build-context/.next/static | |
| cp -r turbo-repo/apps/docs/public build-context/public | |
| cp turbo-repo/docker/nextjs.standalone-docs.Dockerfile build-context/ | |
| - uses: docker/setup-buildx-action@v3 | |
| - uses: docker/login-action@v3 | |
| with: | |
| registry: ${{ env.GHCR_REGISTRY }} | |
| username: ${{ github.actor }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Build and push image | |
| if: needs.prepare.outputs.docs_changed == 'true' | |
| id: docker | |
| uses: docker/build-push-action@v6 | |
| with: | |
| context: build-context/ | |
| file: build-context/nextjs.standalone-docs.Dockerfile | |
| platforms: linux/amd64 | |
| push: true | |
| tags: | | |
| ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_OWNER }}/${{ env.DOCS_IMAGE_NAME }}:${{ needs.prepare.outputs.docs_version }} | |
| ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_OWNER }}/${{ env.DOCS_IMAGE_NAME }}:docs-v${{ needs.prepare.outputs.docs_version }} | |
| ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_OWNER }}/${{ env.DOCS_IMAGE_NAME }}:sha-${{ needs.tag.outputs.short_sha }} | |
| provenance: true | |
| sbom: true | |
| - name: Export image ref | |
| id: image | |
| run: | | |
| if [[ "${{ needs.prepare.outputs.docs_changed }}" == "true" ]]; then | |
| digest="${{ steps.docker.outputs.digest }}" | |
| else | |
| digest="$(docker buildx imagetools inspect "${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_OWNER }}/${{ env.DOCS_IMAGE_NAME }}:docs-v${{ needs.prepare.outputs.docs_version }}" | awk '/^Digest:/ { print $2; exit }')" | |
| fi | |
| echo "image=${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_OWNER }}/${{ env.DOCS_IMAGE_NAME }}@${digest}" >> "$GITHUB_OUTPUT" | |
| build-modulith: | |
| name: Resolve modulith image | |
| runs-on: ubuntu-latest | |
| needs: [prepare, tag] | |
| outputs: | |
| image: ${{ steps.image.outputs.image }} | |
| steps: | |
| - uses: docker/setup-buildx-action@v3 | |
| - uses: docker/login-action@v3 | |
| with: | |
| registry: ${{ env.GHCR_REGISTRY }} | |
| username: ${{ github.actor }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Wait for release modulith image | |
| if: needs.prepare.outputs.modulith_changed == 'true' | |
| env: | |
| SOURCE_IMAGE: ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_OWNER }}/${{ env.MODULITH_IMAGE_NAME }}:sha-${{ needs.tag.outputs.short_sha }} | |
| SOURCE_NATIVE_IMAGE: ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_OWNER }}/${{ env.MODULITH_IMAGE_NAME }}:sha-${{ needs.tag.outputs.short_sha }}-native | |
| run: | | |
| for attempt in {1..60}; do | |
| if docker buildx imagetools inspect "$SOURCE_IMAGE" >/dev/null && | |
| docker buildx imagetools inspect "$SOURCE_NATIVE_IMAGE" >/dev/null; then | |
| echo "Promoting release modulith images $SOURCE_IMAGE and $SOURCE_NATIVE_IMAGE." | |
| exit 0 | |
| fi | |
| echo "Waiting for Quarkus trunk CI to publish release images... ($attempt/60)" | |
| sleep 60 | |
| done | |
| echo "::error::Timed out waiting for Quarkus trunk CI to publish release modulith images." | |
| exit 1 | |
| - name: Promote image tags | |
| if: needs.prepare.outputs.modulith_changed == 'true' | |
| env: | |
| SOURCE_IMAGE: ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_OWNER }}/${{ env.MODULITH_IMAGE_NAME }}:sha-${{ needs.tag.outputs.short_sha }} | |
| SOURCE_NATIVE_IMAGE: ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_OWNER }}/${{ env.MODULITH_IMAGE_NAME }}:sha-${{ needs.tag.outputs.short_sha }}-native | |
| RELEASE_IMAGE: ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_OWNER }}/${{ env.MODULITH_IMAGE_NAME }}:${{ needs.prepare.outputs.modulith_version }} | |
| COMPONENT_IMAGE: ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_OWNER }}/${{ env.MODULITH_IMAGE_NAME }}:modulith-v${{ needs.prepare.outputs.modulith_version }} | |
| RELEASE_NATIVE_IMAGE: ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_OWNER }}/${{ env.MODULITH_IMAGE_NAME }}:${{ needs.prepare.outputs.modulith_version }}-native | |
| COMPONENT_NATIVE_IMAGE: ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_OWNER }}/${{ env.MODULITH_IMAGE_NAME }}:modulith-v${{ needs.prepare.outputs.modulith_version }}-native | |
| run: | | |
| docker buildx imagetools create \ | |
| --tag "$RELEASE_IMAGE" \ | |
| --tag "$COMPONENT_IMAGE" \ | |
| "$SOURCE_IMAGE" | |
| docker buildx imagetools create \ | |
| --tag "$RELEASE_NATIVE_IMAGE" \ | |
| --tag "$COMPONENT_NATIVE_IMAGE" \ | |
| "$SOURCE_NATIVE_IMAGE" | |
| - name: Export image ref | |
| id: image | |
| run: | | |
| digest="$(docker buildx imagetools inspect "${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_OWNER }}/${{ env.MODULITH_IMAGE_NAME }}:modulith-v${{ needs.prepare.outputs.modulith_version }}" | awk '/^Digest:/ { print $2; exit }')" | |
| echo "image=${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_OWNER }}/${{ env.MODULITH_IMAGE_NAME }}@${digest}" >> "$GITHUB_OUTPUT" | |
| build-harness: | |
| name: Resolve harness image | |
| runs-on: ubuntu-latest | |
| needs: [prepare, tag] | |
| outputs: | |
| image: ${{ steps.image.outputs.image }} | |
| steps: | |
| - uses: docker/setup-buildx-action@v3 | |
| - uses: docker/login-action@v3 | |
| with: | |
| registry: ${{ env.GHCR_REGISTRY }} | |
| username: ${{ github.actor }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Wait for release harness image | |
| if: needs.prepare.outputs.harness_changed == 'true' | |
| env: | |
| SOURCE_IMAGE: ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_OWNER }}/${{ env.HARNESS_IMAGE_NAME }}:sha-${{ needs.tag.outputs.short_sha }} | |
| run: | | |
| for attempt in {1..60}; do | |
| if docker buildx imagetools inspect "$SOURCE_IMAGE" >/dev/null; then | |
| echo "Promoting release harness image $SOURCE_IMAGE." | |
| exit 0 | |
| fi | |
| echo "Waiting for Harness CI to publish release image... ($attempt/60)" | |
| sleep 60 | |
| done | |
| echo "::error::Timed out waiting for Harness CI to publish release image." | |
| exit 1 | |
| - name: Promote image tags | |
| if: needs.prepare.outputs.harness_changed == 'true' | |
| env: | |
| SOURCE_IMAGE: ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_OWNER }}/${{ env.HARNESS_IMAGE_NAME }}:sha-${{ needs.tag.outputs.short_sha }} | |
| RELEASE_IMAGE: ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_OWNER }}/${{ env.HARNESS_IMAGE_NAME }}:${{ needs.prepare.outputs.harness_version }} | |
| COMPONENT_IMAGE: ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_OWNER }}/${{ env.HARNESS_IMAGE_NAME }}:harness-v${{ needs.prepare.outputs.harness_version }} | |
| run: | | |
| docker buildx imagetools create \ | |
| --tag "$RELEASE_IMAGE" \ | |
| --tag "$COMPONENT_IMAGE" \ | |
| "$SOURCE_IMAGE" | |
| - name: Export image ref | |
| id: image | |
| run: | | |
| digest="$(docker buildx imagetools inspect "${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_OWNER }}/${{ env.HARNESS_IMAGE_NAME }}:harness-v${{ needs.prepare.outputs.harness_version }}" | awk '/^Digest:/ { print $2; exit }')" | |
| echo "image=${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_OWNER }}/${{ env.HARNESS_IMAGE_NAME }}@${digest}" >> "$GITHUB_OUTPUT" | |
| deploy: | |
| name: Dispatch configured environment deploys | |
| runs-on: ubuntu-latest | |
| needs: [prepare, tag, build-app, build-docs, build-modulith, build-harness] | |
| environment: | |
| name: app-deploy | |
| steps: | |
| - name: Dispatch private deployment workflow | |
| env: | |
| GH_TOKEN: ${{ secrets.DEPLOY_DISPATCH_TOKEN }} | |
| DEPLOY_DISPATCH_REPO: ${{ secrets.DEPLOY_DISPATCH_REPO }} | |
| RELEASE_VERSION: ${{ needs.prepare.outputs.release_version }} | |
| RELEASE_NOTES_VERSION: v${{ needs.prepare.outputs.release_notes_version }} | |
| BUILD_CHANNEL: release | |
| STACK_TAG: ${{ needs.prepare.outputs.stack_tag }} | |
| GIT_SHA: ${{ needs.tag.outputs.sha }} | |
| SHORT_SHA: ${{ needs.tag.outputs.short_sha }} | |
| WORKFLOW_RUN_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} | |
| APP_TAG: ${{ needs.prepare.outputs.app_tag }} | |
| DOCS_TAG: ${{ needs.prepare.outputs.docs_tag }} | |
| MODULITH_TAG: ${{ needs.prepare.outputs.modulith_tag }} | |
| APP_IMAGE_REPOSITORY: ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_OWNER }}/${{ env.APP_IMAGE_NAME }} | |
| APP_IMAGE_TAG: app-v${{ needs.prepare.outputs.app_version }} | |
| APP_IMAGE_REF: ${{ needs.build-app.outputs.image }} | |
| APP_CHANGED: ${{ needs.prepare.outputs.app_changed }} | |
| APP_VERSION: ${{ needs.prepare.outputs.app_version }} | |
| DOCS_IMAGE_REPOSITORY: ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_OWNER }}/${{ env.DOCS_IMAGE_NAME }} | |
| DOCS_IMAGE_TAG: docs-v${{ needs.prepare.outputs.docs_version }} | |
| DOCS_IMAGE_REF: ${{ needs.build-docs.outputs.image }} | |
| DOCS_CHANGED: ${{ needs.prepare.outputs.docs_changed }} | |
| DOCS_VERSION: ${{ needs.prepare.outputs.docs_version }} | |
| MODULITH_IMAGE_REPOSITORY: ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_OWNER }}/${{ env.MODULITH_IMAGE_NAME }} | |
| MODULITH_IMAGE_TAG: modulith-v${{ needs.prepare.outputs.modulith_version }} | |
| MODULITH_IMAGE_REF: ${{ needs.build-modulith.outputs.image }} | |
| MODULITH_CHANGED: ${{ needs.prepare.outputs.modulith_changed }} | |
| MODULITH_VERSION: ${{ needs.prepare.outputs.modulith_version }} | |
| HARNESS_TAG: ${{ needs.prepare.outputs.harness_tag }} | |
| HARNESS_IMAGE_REPOSITORY: ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_OWNER }}/${{ env.HARNESS_IMAGE_NAME }} | |
| HARNESS_IMAGE_TAG: harness-v${{ needs.prepare.outputs.harness_version }} | |
| HARNESS_IMAGE_REF: ${{ needs.build-harness.outputs.image }} | |
| HARNESS_CHANGED: ${{ needs.prepare.outputs.harness_changed }} | |
| HARNESS_VERSION: ${{ needs.prepare.outputs.harness_version }} | |
| run: | | |
| DEPLOY_DISPATCH_REPO="${DEPLOY_DISPATCH_REPO:-microboxlabs/miot-deployments}" | |
| if [[ -z "$GH_TOKEN" ]]; then | |
| echo "DEPLOY_DISPATCH_TOKEN is required to dispatch the private deployment repository." >&2 | |
| exit 1 | |
| fi | |
| BUILT_AT="$(date -u +'%Y-%m-%dT%H:%M:%SZ')" | |
| jq -n \ | |
| --arg release_version "$RELEASE_VERSION" \ | |
| --arg release_notes_version "$RELEASE_NOTES_VERSION" \ | |
| --arg build_channel "$BUILD_CHANNEL" \ | |
| --arg stack_tag "$STACK_TAG" \ | |
| --arg git_sha "$GIT_SHA" \ | |
| --arg short_sha "$SHORT_SHA" \ | |
| --arg built_at "$BUILT_AT" \ | |
| --arg workflow_run_url "$WORKFLOW_RUN_URL" \ | |
| --arg app_tag "$APP_TAG" \ | |
| --arg app_changed "$APP_CHANGED" \ | |
| --arg app_version "$APP_VERSION" \ | |
| --arg docs_tag "$DOCS_TAG" \ | |
| --arg docs_changed "$DOCS_CHANGED" \ | |
| --arg docs_version "$DOCS_VERSION" \ | |
| --arg modulith_tag "$MODULITH_TAG" \ | |
| --arg modulith_changed "$MODULITH_CHANGED" \ | |
| --arg modulith_version "$MODULITH_VERSION" \ | |
| --arg app_image_repository "$APP_IMAGE_REPOSITORY" \ | |
| --arg app_image_tag "$APP_IMAGE_TAG" \ | |
| --arg app_image_ref "$APP_IMAGE_REF" \ | |
| --arg docs_image_repository "$DOCS_IMAGE_REPOSITORY" \ | |
| --arg docs_image_tag "$DOCS_IMAGE_TAG" \ | |
| --arg docs_image_ref "$DOCS_IMAGE_REF" \ | |
| --arg modulith_image_repository "$MODULITH_IMAGE_REPOSITORY" \ | |
| --arg modulith_image_tag "$MODULITH_IMAGE_TAG" \ | |
| --arg modulith_image_ref "$MODULITH_IMAGE_REF" \ | |
| --arg harness_tag "$HARNESS_TAG" \ | |
| --arg harness_changed "$HARNESS_CHANGED" \ | |
| --arg harness_version "$HARNESS_VERSION" \ | |
| --arg harness_image_repository "$HARNESS_IMAGE_REPOSITORY" \ | |
| --arg harness_image_tag "$HARNESS_IMAGE_TAG" \ | |
| --arg harness_image_ref "$HARNESS_IMAGE_REF" \ | |
| '{ | |
| event_type: "miot-stack-release", | |
| client_payload: { | |
| release_version: $release_version, | |
| release_notes_version: $release_notes_version, | |
| channel: $build_channel, | |
| stack_tag: $stack_tag, | |
| git_sha: $git_sha, | |
| short_sha: $short_sha, | |
| built_at: $built_at, | |
| workflow_run_url: $workflow_run_url, | |
| manifest_version: 1, | |
| components: { | |
| app: { | |
| changed: ($app_changed == "true"), | |
| version: $app_version, | |
| tag: $app_tag, | |
| image_repository: $app_image_repository, | |
| image_tag: $app_image_tag, | |
| image_ref: $app_image_ref | |
| }, | |
| docs: { | |
| changed: ($docs_changed == "true"), | |
| version: $docs_version, | |
| tag: $docs_tag, | |
| image_repository: $docs_image_repository, | |
| image_tag: $docs_image_tag, | |
| image_ref: $docs_image_ref | |
| }, | |
| modulith: { | |
| changed: ($modulith_changed == "true"), | |
| version: $modulith_version, | |
| tag: $modulith_tag, | |
| image_repository: $modulith_image_repository, | |
| image_tag: $modulith_image_tag, | |
| image_ref: $modulith_image_ref | |
| }, | |
| harness: { | |
| changed: ($harness_changed == "true"), | |
| version: $harness_version, | |
| tag: $harness_tag, | |
| image_repository: $harness_image_repository, | |
| image_tag: $harness_image_tag, | |
| image_ref: $harness_image_ref | |
| } | |
| } | |
| } | |
| }' > stack-dispatch-payload.json | |
| jq -n \ | |
| --arg release_version "$RELEASE_VERSION" \ | |
| --arg release_notes_version "$RELEASE_NOTES_VERSION" \ | |
| --arg build_channel "$BUILD_CHANNEL" \ | |
| --arg stack_tag "$STACK_TAG" \ | |
| --arg git_sha "$GIT_SHA" \ | |
| --arg short_sha "$SHORT_SHA" \ | |
| --arg built_at "$BUILT_AT" \ | |
| --arg workflow_run_url "$WORKFLOW_RUN_URL" \ | |
| --arg app_tag "$APP_TAG" \ | |
| --arg app_changed "$APP_CHANGED" \ | |
| --arg app_version "$APP_VERSION" \ | |
| --arg docs_tag "$DOCS_TAG" \ | |
| --arg docs_changed "$DOCS_CHANGED" \ | |
| --arg docs_version "$DOCS_VERSION" \ | |
| --arg modulith_tag "$MODULITH_TAG" \ | |
| --arg modulith_changed "$MODULITH_CHANGED" \ | |
| --arg modulith_version "$MODULITH_VERSION" \ | |
| --arg app_image_repository "$APP_IMAGE_REPOSITORY" \ | |
| --arg app_image_tag "$APP_IMAGE_TAG" \ | |
| --arg app_image_ref "$APP_IMAGE_REF" \ | |
| --arg docs_image_repository "$DOCS_IMAGE_REPOSITORY" \ | |
| --arg docs_image_tag "$DOCS_IMAGE_TAG" \ | |
| --arg docs_image_ref "$DOCS_IMAGE_REF" \ | |
| --arg modulith_image_repository "$MODULITH_IMAGE_REPOSITORY" \ | |
| --arg modulith_image_tag "$MODULITH_IMAGE_TAG" \ | |
| --arg modulith_image_ref "$MODULITH_IMAGE_REF" \ | |
| --arg harness_tag "$HARNESS_TAG" \ | |
| --arg harness_changed "$HARNESS_CHANGED" \ | |
| --arg harness_version "$HARNESS_VERSION" \ | |
| --arg harness_image_repository "$HARNESS_IMAGE_REPOSITORY" \ | |
| --arg harness_image_tag "$HARNESS_IMAGE_TAG" \ | |
| --arg harness_image_ref "$HARNESS_IMAGE_REF" \ | |
| '{ | |
| release_version: $release_version, | |
| stack_version: $release_version, | |
| release_notes_version: $release_notes_version, | |
| channel: $build_channel, | |
| stack_tag: $stack_tag, | |
| git_sha: $git_sha, | |
| short_sha: $short_sha, | |
| built_at: $built_at, | |
| workflow_run_url: $workflow_run_url, | |
| manifest_version: 1, | |
| app_tag: $app_tag, | |
| docs_tag: $docs_tag, | |
| modulith_tag: $modulith_tag, | |
| harness_tag: $harness_tag, | |
| app_image_repository: $app_image_repository, | |
| app_image_tag: $app_image_tag, | |
| app_image_ref: $app_image_ref, | |
| docs_image_repository: $docs_image_repository, | |
| docs_image_tag: $docs_image_tag, | |
| docs_image_ref: $docs_image_ref, | |
| modulith_image_repository: $modulith_image_repository, | |
| modulith_image_tag: $modulith_image_tag, | |
| modulith_image_ref: $modulith_image_ref, | |
| harness_image_repository: $harness_image_repository, | |
| harness_image_tag: $harness_image_tag, | |
| harness_image_ref: $harness_image_ref, | |
| components: { | |
| app: { | |
| changed: ($app_changed == "true"), | |
| version: $app_version, | |
| tag: $app_tag, | |
| image_repository: $app_image_repository, | |
| image_tag: $app_image_tag, | |
| image_ref: $app_image_ref | |
| }, | |
| docs: { | |
| changed: ($docs_changed == "true"), | |
| version: $docs_version, | |
| tag: $docs_tag, | |
| image_repository: $docs_image_repository, | |
| image_tag: $docs_image_tag, | |
| image_ref: $docs_image_ref | |
| }, | |
| modulith: { | |
| changed: ($modulith_changed == "true"), | |
| version: $modulith_version, | |
| tag: $modulith_tag, | |
| image_repository: $modulith_image_repository, | |
| image_tag: $modulith_image_tag, | |
| image_ref: $modulith_image_ref | |
| }, | |
| harness: { | |
| changed: ($harness_changed == "true"), | |
| version: $harness_version, | |
| tag: $harness_tag, | |
| image_repository: $harness_image_repository, | |
| image_tag: $harness_image_tag, | |
| image_ref: $harness_image_ref | |
| } | |
| } | |
| }' > stack-manifest.json | |
| cat stack-dispatch-payload.json | | |
| gh api "repos/${DEPLOY_DISPATCH_REPO}/dispatches" \ | |
| --method POST \ | |
| --input - | |
| echo "Dispatched MIOT v${RELEASE_VERSION} deployment to ${DEPLOY_DISPATCH_REPO}." | |
| - name: Upload stack manifest to release | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| run: gh release upload "${{ needs.prepare.outputs.stack_tag }}" stack-manifest.json --clobber --repo "$GITHUB_REPOSITORY" | |
| close-milestones: | |
| name: Close release milestones | |
| runs-on: ubuntu-latest | |
| needs: [prepare, tag, deploy] | |
| steps: | |
| - uses: actions/checkout@v5 | |
| with: | |
| ref: ${{ needs.tag.outputs.sha }} | |
| - name: Close private and OSS milestones | |
| env: | |
| GH_TOKEN: ${{ secrets.RELEASE_BOT_TOKEN }} | |
| GH_PROJECT_OWNER: microboxlabs | |
| run: | | |
| ./turbo-repo/generative_ai/tools/sh/close-release-milestones.sh \ | |
| --release "${{ needs.prepare.outputs.private_milestone }}" \ | |
| --oss "${{ needs.prepare.outputs.oss_milestone }}" |