diff --git a/.config/mise/lib/calver.sh b/.config/mise/lib/calver.sh new file mode 100644 index 00000000000..1d4aeecad9e --- /dev/null +++ b/.config/mise/lib/calver.sh @@ -0,0 +1,16 @@ +# shellcheck shell=bash +# decide reads " " lines of the +# existing version tags on stdin. It prints "released " when a +# tag already points at , otherwise "next ". +decide() { + local digest="$1" today="$2" tag tagdigest n=0 + while read -r tag tagdigest; do + [ -n "$tag" ] || continue + if [ "$tagdigest" = "$digest" ]; then + echo "released $tag" + return 0 + fi + case "$tag" in "$today".*) n=$((n + 1)) ;; esac + done + echo "next $today.$n" +} diff --git a/.config/mise/lib/ghcr.sh b/.config/mise/lib/ghcr.sh new file mode 100644 index 00000000000..f414c3135e5 --- /dev/null +++ b/.config/mise/lib/ghcr.sh @@ -0,0 +1,18 @@ +# shellcheck shell=bash +# ghcr_manifest_status prints the HTTP status of +# the manifest. The registry's anonymous token endpoint is not enough for a +# private package, so the bearer token is requested with the given credentials. +ghcr_manifest_status() { + local image="$1" tag="$2" actor="$3" token="$4" bearer + # Command substitution does not inherit errexit, so fail the token request + # here instead of sending an empty bearer and reporting a misleading 401. + bearer="$(curl -fsS -u "$actor:$token" \ + "https://ghcr.io/token?service=ghcr.io&scope=repository:${image#ghcr.io/}:pull" | jq -er .token)" || { + echo "ghcr.io token request failed for $image" >&2 + return 1 + } + curl -sS -o /dev/null -w '%{http_code}' \ + -H "Authorization: Bearer $bearer" \ + -H 'Accept: application/vnd.oci.image.index.v1+json, application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json' \ + "https://ghcr.io/v2/${image#ghcr.io/}/manifests/$tag" +} diff --git a/.config/mise/lib/image.sh b/.config/mise/lib/image.sh new file mode 100644 index 00000000000..28ce9e46adc --- /dev/null +++ b/.config/mise/lib/image.sh @@ -0,0 +1,5 @@ +# shellcheck shell=bash +# shellcheck disable=SC2034 +# /images//: the namespace is the source repository +# and the name is the deployable (ADR 6497710059). +IMAGE=ghcr.io/trogonstack/images/cliproxyapi/cli-proxy-api diff --git a/.config/mise/tasks/github/workflows/trogonstack-image/image-name b/.config/mise/tasks/github/workflows/trogonstack-image/image-name new file mode 100755 index 00000000000..460554fb683 --- /dev/null +++ b/.config/mise/tasks/github/workflows/trogonstack-image/image-name @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +#MISE description="Expose the image name as the step output name" +set -euo pipefail +source "$MISE_PROJECT_ROOT/.config/mise/lib/image.sh" +echo "name=$IMAGE" >> "$GITHUB_OUTPUT" diff --git a/.config/mise/tasks/github/workflows/trogonstack-image/refresh-model-catalogs b/.config/mise/tasks/github/workflows/trogonstack-image/refresh-model-catalogs new file mode 100755 index 00000000000..b686e7f350b --- /dev/null +++ b/.config/mise/tasks/github/workflows/trogonstack-image/refresh-model-catalogs @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +#MISE description="Refresh the model catalogs with upstream's script" +set -euo pipefail +exec bash "$MISE_PROJECT_ROOT/.github/scripts/refresh-model-catalogs.sh" diff --git a/.config/mise/tasks/github/workflows/trogonstack-image/summary b/.config/mise/tasks/github/workflows/trogonstack-image/summary new file mode 100755 index 00000000000..591265f83ce --- /dev/null +++ b/.config/mise/tasks/github/workflows/trogonstack-image/summary @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +#MISE description="Write the pushed image reference to the step summary" +set -euo pipefail +echo "\`${REF}\`" >> "$GITHUB_STEP_SUMMARY" diff --git a/.config/mise/tasks/github/workflows/trogonstack-release/add-calver-tag b/.config/mise/tasks/github/workflows/trogonstack-release/add-calver-tag new file mode 100755 index 00000000000..64ee2b4cc20 --- /dev/null +++ b/.config/mise/tasks/github/workflows/trogonstack-release/add-calver-tag @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +#MISE description="Add the next CalVer tag to the image built for the commit" +set -euo pipefail +source "$MISE_PROJECT_ROOT/.config/mise/lib/image.sh" +source "$MISE_PROJECT_ROOT/.config/mise/lib/calver.sh" + +digest="$(crane digest "$IMAGE:$COMMIT" 2>/dev/null)" || { + echo "No image $IMAGE:$COMMIT. Run trogonstack-image for that commit first." >&2 + exit 1 +} + +# A registry error must stop the release: a missing pair would hide an earlier +# release of this commit or reuse a taken N. Only "no version tags" is fine. +all_tags="$(crane ls "$IMAGE")" +pairs="" +for tag in $(printf '%s\n' "$all_tags" | grep -E '^[0-9]{4}\.[0-9]{3,4}\.[0-9]+$' || true); do + tagdigest="$(crane digest "$IMAGE:$tag")" + pairs+="$tag $tagdigest"$'\n' +done + +# Month without padding followed by a two digit day: 5 April is 405. +today="$(date -u +%Y.%-m%d)" +result="$(printf '%s\n' "$pairs" | decide "$digest" "$today")" + +case "$result" in + released\ *) + tag="${result#released }" + echo "$COMMIT already released as $tag ($digest)" + printf 'Already released as %s\n\n%s@%s\n' "$tag" "$IMAGE" "$digest" >> "$GITHUB_STEP_SUMMARY" + exit 0 + ;; +esac + +version="${result#next }" +crane tag "$IMAGE:$COMMIT" "$version" + +tagged="$(crane digest "$IMAGE:$version")" +if [ "$tagged" != "$digest" ]; then + echo "Tag $version resolves to $tagged, expected $digest" >&2 + exit 1 +fi +printf 'Released %s\n\n%s:%s@%s\n' "$version" "$IMAGE" "$version" "$digest" >> "$GITHUB_STEP_SUMMARY" diff --git a/.config/mise/tasks/github/workflows/trogonstack-release/registry-login b/.config/mise/tasks/github/workflows/trogonstack-release/registry-login new file mode 100755 index 00000000000..176e1cfd949 --- /dev/null +++ b/.config/mise/tasks/github/workflows/trogonstack-release/registry-login @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +#MISE description="Log crane in to ghcr.io" +set -euo pipefail +printf '%s' "$TOKEN" | crane auth login ghcr.io -u "$ACTOR" --password-stdin diff --git a/.config/mise/tasks/github/workflows/trogonstack-release/resolve-commit b/.config/mise/tasks/github/workflows/trogonstack-release/resolve-commit new file mode 100755 index 00000000000..c578d58f1d2 --- /dev/null +++ b/.config/mise/tasks/github/workflows/trogonstack-release/resolve-commit @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +#MISE description="Resolve the commit to release" +set -euo pipefail +sha="$(gh api "repos/$GITHUB_REPOSITORY/commits/${INPUT_COMMIT:-main}" --jq .sha)" +echo "sha=$sha" >> "$GITHUB_OUTPUT" diff --git a/.config/mise/tasks/github/workflows/trogonstack-sync/merge-upstream b/.config/mise/tasks/github/workflows/trogonstack-sync/merge-upstream new file mode 100755 index 00000000000..e0526daf8b9 --- /dev/null +++ b/.config/mise/tasks/github/workflows/trogonstack-sync/merge-upstream @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +#MISE description="Merge the upstream tag, or open a conflict issue and fail" +set -euo pipefail +git config user.name 'github-actions[bot]' +git config user.email '41898282+github-actions[bot]@users.noreply.github.com' +if git merge --no-ff --no-edit -m "chore(sync): merge upstream $TAG" "refs/tags/$TAG"; then + echo "conflict=false" >> "$GITHUB_OUTPUT" + exit 0 +fi +echo "conflict=true" >> "$GITHUB_OUTPUT" +git diff --name-only --diff-filter=U > "$RUNNER_TEMP/conflicts.txt" +git merge --abort +title="Upstream sync conflict: $TAG" +{ + echo "Merging upstream $TAG into main hit conflicts." + echo + echo "Conflicting files:" + sed 's/^/- /' "$RUNNER_TEMP/conflicts.txt" + echo + echo "Run: $RUN_URL" + echo + echo "Resolve locally with: git merge $TAG, then push the result to main." +} > "$RUNNER_TEMP/conflict-body.txt" +# gh under GITHUB_TOKEN is non-interactive: pass the body by file so no +# editor or prompt is needed, and skip labels that may not exist. +existing="$(gh issue list --state open --limit 200 --json number,title \ + --jq "[.[] | select(.title == \"$title\")] | length")" +if [ "$existing" = "0" ]; then + gh issue create --title "$title" --body-file "$RUNNER_TEMP/conflict-body.txt" +else + echo "Open conflict issue already exists" +fi +exit 1 diff --git a/.config/mise/tasks/github/workflows/trogonstack-sync/pick-tag b/.config/mise/tasks/github/workflows/trogonstack-sync/pick-tag new file mode 100755 index 00000000000..7067d11a8f8 --- /dev/null +++ b/.config/mise/tasks/github/workflows/trogonstack-sync/pick-tag @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +#MISE description="Pick the newest stable upstream tag and report whether main contains it" +set -euo pipefail +git fetch --no-tags "$UPSTREAM" '+refs/tags/v*:refs/tags/v*' +tag="$(git tag -l 'v*' | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -n 1)" +if [ -z "$tag" ]; then + echo "No stable upstream tag found" >&2 + exit 1 +fi +echo "tag=$tag" >> "$GITHUB_OUTPUT" +if git merge-base --is-ancestor "refs/tags/$tag" HEAD; then + echo "up_to_date=true" >> "$GITHUB_OUTPUT" + echo "main already contains $tag" >> "$GITHUB_STEP_SUMMARY" +else + echo "up_to_date=false" >> "$GITHUB_OUTPUT" +fi diff --git a/.config/mise/tasks/github/workflows/trogonstack-sync/publish-pull-request b/.config/mise/tasks/github/workflows/trogonstack-sync/publish-pull-request new file mode 100755 index 00000000000..03369b7ae69 --- /dev/null +++ b/.config/mise/tasks/github/workflows/trogonstack-sync/publish-pull-request @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +#MISE description="Publish a merge that fails verification as a pull request and fail" +set -euo pipefail +branch="sync/$TAG" +git fetch origin "$branch" || true +git push --force-with-lease origin "HEAD:refs/heads/$branch" +title="chore(sync): merge upstream $TAG" +{ + echo "Merging upstream $TAG into main is clean but build or tests failed." + echo + echo "Failed run: $RUN_URL" + echo + echo "Fix on this branch, then merge it into main." +} > "$RUNNER_TEMP/pr-body.txt" +existing="$(gh pr list --state open --head "$branch" --json number --jq 'length')" +if [ "$existing" = "0" ]; then + gh pr create --base main --head "$branch" --title "$title" \ + --body-file "$RUNNER_TEMP/pr-body.txt" +else + echo "Open sync PR already exists" +fi +exit 1 diff --git a/.config/mise/tasks/github/workflows/trogonstack-sync/push-main b/.config/mise/tasks/github/workflows/trogonstack-sync/push-main new file mode 100755 index 00000000000..09f9f7b5b7f --- /dev/null +++ b/.config/mise/tasks/github/workflows/trogonstack-sync/push-main @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +#MISE description="Push the verified merge to main and dispatch the image build" +set -euo pipefail +# GITHUB_TOKEN cannot push commits that touch .github/workflows, and +# upstream changes those every month or so. Say so in an issue +# instead of leaving a bare red run. +if ! git push origin HEAD:main; then + title="Upstream sync needs a manual push: $TAG" + if git diff --quiet HEAD^1 HEAD -- .github/workflows; then + reason="The push to main was rejected." + else + reason="The merge changes .github/workflows, which GITHUB_TOKEN may not push." + fi + printf '%s\n\nRun: %s\n\nPush it locally with: git merge %s, then git push origin main.\n' \ + "$reason" "$RUN_URL" "$TAG" > "$RUNNER_TEMP/push-body.txt" + existing="$(gh issue list --state open --limit 200 --json title \ + --jq "[.[] | select(.title == \"$title\")] | length")" + if [ "$existing" = "0" ]; then + gh issue create --title "$title" --body-file "$RUNNER_TEMP/push-body.txt" + fi + exit 1 +fi +# Pushes made with GITHUB_TOKEN do not trigger workflows, but +# workflow_dispatch does, so start the image build explicitly. +gh workflow run trogonstack-image.yml --ref main -f release=true +echo "Merged upstream $TAG into main and dispatched the image build with release" >> "$GITHUB_STEP_SUMMARY" diff --git a/.config/mise/tasks/github/workflows/trogonstack-sync/release-current b/.config/mise/tasks/github/workflows/trogonstack-sync/release-current new file mode 100755 index 00000000000..f6c6c05c823 --- /dev/null +++ b/.config/mise/tasks/github/workflows/trogonstack-sync/release-current @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +#MISE description="Release current main, building it first when it has no image" +set -euo pipefail +source "$MISE_PROJECT_ROOT/.config/mise/lib/image.sh" +source "$MISE_PROJECT_ROOT/.config/mise/lib/ghcr.sh" + +head="$(git rev-parse HEAD)" +# shellcheck disable=SC2153 +status="$(ghcr_manifest_status "$IMAGE" "$head" "$ACTOR" "$REGISTRY_TOKEN")" +# Only a 404 means the image is missing. Any other answer is an outage +# or an auth problem, and building again would move the commit tag. +if [ "$status" = "200" ]; then + # Release the commit whose image was checked, even if main moves meanwhile. + gh workflow run trogonstack-release.yml --ref main -f commit="$head" + echo "main is current with upstream; dispatched the release for $head" >> "$GITHUB_STEP_SUMMARY" +elif [ "$status" = "404" ]; then + gh workflow run trogonstack-image.yml --ref main -f release=true + echo "main is current with upstream; $IMAGE:$head not found, dispatched the image build with release" >> "$GITHUB_STEP_SUMMARY" +else + echo "Registry answered HTTP $status for $IMAGE:$head" >&2 + exit 1 +fi diff --git a/.config/mise/tasks/github/workflows/trogonstack-sync/verify b/.config/mise/tasks/github/workflows/trogonstack-sync/verify new file mode 100755 index 00000000000..d718a97323d --- /dev/null +++ b/.config/mise/tasks/github/workflows/trogonstack-sync/verify @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +#MISE description="Build and test the merged tree" +set -euo pipefail +go build ./... +go test ./... diff --git a/.github/actions/setup-mise/action.yml b/.github/actions/setup-mise/action.yml new file mode 100644 index 00000000000..67881e7a4df --- /dev/null +++ b/.github/actions/setup-mise/action.yml @@ -0,0 +1,24 @@ +name: Set up mise +description: Install a pinned mise release after verifying its sha256 checksum. + +# jdx/mise-action is not an allowed action, so the release binary is installed +# directly. This runs before mise exists, so it cannot be a mise task +# (ADR 6647967335 puts composite action scripts under +# .config/mise/tasks/github/actions// only when mise can run them). +runs: + using: composite + steps: + - shell: bash + env: + MISE_VERSION: v2026.10.15 + MISE_SHA256: 1c2d85dc42bef51e0bb20780093c746d6e12dfd15ca461f3979bd52cc214bc19 + run: | + set -euo pipefail + archive="$RUNNER_TEMP/mise.tar.gz" + curl -fsSL -o "$archive" "https://github.com/jdx/mise/releases/download/$MISE_VERSION/mise-$MISE_VERSION-linux-x64.tar.gz" + echo "$MISE_SHA256 $archive" | sha256sum --check --strict + mkdir -p "$RUNNER_TEMP/mise-install" + tar -xzf "$archive" -C "$RUNNER_TEMP/mise-install" + echo "$RUNNER_TEMP/mise-install/mise/bin" >> "$GITHUB_PATH" + # The checkout is ours, so its mise.toml needs no trust prompt. + echo "MISE_TRUSTED_CONFIG_PATHS=$GITHUB_WORKSPACE" >> "$GITHUB_ENV" diff --git a/.github/workflows/trogonstack-image.yml b/.github/workflows/trogonstack-image.yml index 9c1823ce898..53c0e7903c6 100644 --- a/.github/workflows/trogonstack-image.yml +++ b/.github/workflows/trogonstack-image.yml @@ -2,15 +2,22 @@ name: trogonstack-image on: push: - branches: [main, 'yordis/**'] + branches: [main] pull_request: workflow_dispatch: + inputs: + release: + description: Tag the built image with the next CalVer version (main only) + type: boolean + default: false permissions: contents: read env: - IMAGE: ghcr.io/trogonstack/cli-proxy-api + # A pull request pushes only when it comes from this repository, so fork PRs + # build without a registry write. + PUSH: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} jobs: image: @@ -27,49 +34,58 @@ jobs: with: go-version-file: go.mod cache: true + - uses: ./.github/actions/setup-mise + - id: image + run: mise run github:workflows:trogonstack-image:image-name - name: Refresh models catalog - run: bash .github/scripts/refresh-model-catalogs.sh + run: mise run github:workflows:trogonstack-image:refresh-model-catalogs - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4 - uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0 - if: github.event_name != 'pull_request' + if: env.PUSH == 'true' with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Compute fork version tag - id: forkver - if: github.ref == 'refs/heads/main' - run: | - # Upstream tags are fetched into the runner only, never pushed back. - git fetch --no-tags https://github.com/router-for-me/CLIProxyAPI.git '+refs/tags/v*:refs/tags/v*' - base="$(git tag --merged HEAD -l 'v*' | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -n 1)" - if [ -z "$base" ]; then - echo "No upstream release tag reachable from HEAD" >&2 - exit 1 - fi - n="$(git rev-list --count "$base..HEAD")" - echo "value=${base}-trogonstack.${n}" >> "$GITHUB_OUTPUT" - echo "Fork image tag: \`${base}-trogonstack.${n}\`" >> "$GITHUB_STEP_SUMMARY" - id: meta uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6 + env: + # Annotations land on both the image index and its manifest. + DOCKER_METADATA_ANNOTATIONS_LEVELS: manifest,index + # Label the pull request head commit, not the merge commit. + DOCKER_METADATA_PR_HEAD_SHA: 'true' with: - images: ${{ env.IMAGE }} + images: ${{ steps.image.outputs.name }} tags: | - type=sha,format=long - type=ref,event=branch - type=raw,value=${{ steps.forkver.outputs.value || 'unused' }},enable=${{ github.ref == 'refs/heads/main' }} + type=sha,format=long,prefix=,enable=${{ github.event_name != 'pull_request' }} + type=raw,value=pr-${{ github.event.pull_request.head.sha }},enable=${{ github.event_name == 'pull_request' }} - id: build uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7 with: context: . + # The ADR requires a multi-platform image index. The cluster is amd64 + # only, so the index holds one platform plus its provenance + # attestation. It exists so arm64 can be added here without changing + # any tag or path. platforms: linux/amd64 - push: ${{ github.event_name != 'pull_request' }} + provenance: mode=min + push: ${{ env.PUSH == 'true' }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + annotations: ${{ steps.meta.outputs.annotations }} build-args: | VERSION=${{ github.ref_name }} - COMMIT=${{ github.sha }} - - if: github.event_name != 'pull_request' + COMMIT=${{ github.event.pull_request.head.sha || github.sha }} + - if: env.PUSH == 'true' env: - REF: ${{ env.IMAGE }}@${{ steps.build.outputs.digest }} - run: echo "\`${REF}\`" >> "$GITHUB_STEP_SUMMARY" + REF: ${{ steps.image.outputs.name }}@${{ steps.build.outputs.digest }} + run: mise run github:workflows:trogonstack-image:summary + + release: + needs: image + if: github.event_name == 'workflow_dispatch' && inputs.release && github.ref == 'refs/heads/main' + permissions: + contents: read + packages: write + uses: ./.github/workflows/trogonstack-release.yml + with: + commit: ${{ github.sha }} diff --git a/.github/workflows/trogonstack-release.yml b/.github/workflows/trogonstack-release.yml new file mode 100644 index 00000000000..492ef671480 --- /dev/null +++ b/.github/workflows/trogonstack-release.yml @@ -0,0 +1,60 @@ +name: trogonstack-release + +# Releases an image by adding a CalVer tag (YYYY.MDD.N, ADR 6497710059) to the +# image already built for a commit. It never builds, so the digest of the +# release is the digest of the commit build. Safe to run repeatedly: a commit +# that already carries a version tag is left alone. + +on: + workflow_call: + inputs: + commit: + description: Full commit SHA to release. Defaults to the current HEAD of main. + type: string + required: false + default: '' + workflow_dispatch: + inputs: + commit: + description: Commit to release. Defaults to the current HEAD of main. + type: string + required: false + default: '' + +permissions: + contents: read + +concurrency: + group: trogonstack-release + cancel-in-progress: false + +env: + GH_TOKEN: ${{ github.token }} + +jobs: + release: + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + packages: write + steps: + # The mise tasks and configuration live in the repository. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: ./.github/actions/setup-mise + - name: Log in to ghcr.io + env: + ACTOR: ${{ github.actor }} + TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: mise run github:workflows:trogonstack-release:registry-login + - name: Resolve the commit + id: commit + env: + INPUT_COMMIT: ${{ inputs.commit }} + run: mise run github:workflows:trogonstack-release:resolve-commit + - name: Add the CalVer tag + env: + COMMIT: ${{ steps.commit.outputs.sha }} + run: mise run github:workflows:trogonstack-release:add-calver-tag diff --git a/.github/workflows/trogonstack-sync.yml b/.github/workflows/trogonstack-sync.yml index 084273b01ca..ac774d13147 100644 --- a/.github/workflows/trogonstack-sync.yml +++ b/.github/workflows/trogonstack-sync.yml @@ -16,6 +16,7 @@ permissions: pull-requests: write issues: write actions: write + packages: read concurrency: group: trogonstack-sync @@ -37,60 +38,17 @@ jobs: ref: main fetch-depth: 0 + - uses: ./.github/actions/setup-mise + - name: Pick newest stable upstream tag id: tag - run: | - git fetch --no-tags "$UPSTREAM" '+refs/tags/v*:refs/tags/v*' - tag="$(git tag -l 'v*' | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -n 1)" - if [ -z "$tag" ]; then - echo "No stable upstream tag found" >&2 - exit 1 - fi - echo "tag=$tag" >> "$GITHUB_OUTPUT" - if git merge-base --is-ancestor "refs/tags/$tag" HEAD; then - echo "up_to_date=true" >> "$GITHUB_OUTPUT" - echo "main already contains $tag" >> "$GITHUB_STEP_SUMMARY" - else - echo "up_to_date=false" >> "$GITHUB_OUTPUT" - fi - + run: mise run github:workflows:trogonstack-sync:pick-tag - name: Merge upstream tag id: merge if: steps.tag.outputs.up_to_date == 'false' env: TAG: ${{ steps.tag.outputs.tag }} - run: | - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - if git merge --no-ff --no-edit -m "chore(sync): merge upstream $TAG" "refs/tags/$TAG"; then - echo "conflict=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - echo "conflict=true" >> "$GITHUB_OUTPUT" - git diff --name-only --diff-filter=U > "$RUNNER_TEMP/conflicts.txt" - git merge --abort - title="Upstream sync conflict: $TAG" - { - echo "Merging upstream $TAG into main hit conflicts." - echo - echo "Conflicting files:" - sed 's/^/- /' "$RUNNER_TEMP/conflicts.txt" - echo - echo "Run: $RUN_URL" - echo - echo "Resolve locally with: git merge $TAG, then push the result to main." - } > "$RUNNER_TEMP/conflict-body.txt" - # gh under GITHUB_TOKEN is non-interactive: pass the body by file so no - # editor or prompt is needed, and skip labels that may not exist. - existing="$(gh issue list --state open --limit 200 --json number,title \ - --jq "[.[] | select(.title == \"$title\")] | length")" - if [ "$existing" = "0" ]; then - gh issue create --title "$title" --body-file "$RUNNER_TEMP/conflict-body.txt" - else - echo "Open conflict issue already exists" - fi - exit 1 - + run: mise run github:workflows:trogonstack-sync:merge-upstream - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 if: steps.tag.outputs.up_to_date == 'false' with: @@ -104,60 +62,23 @@ jobs: id: verify if: steps.tag.outputs.up_to_date == 'false' continue-on-error: true - run: | - go build ./... - go test ./... - + run: mise run github:workflows:trogonstack-sync:verify - name: Publish failing merge as a pull request if: steps.tag.outputs.up_to_date == 'false' && steps.verify.outcome == 'failure' env: TAG: ${{ steps.tag.outputs.tag }} - run: | - branch="sync/$TAG" - git fetch origin "$branch" || true - git push --force-with-lease origin "HEAD:refs/heads/$branch" - title="chore(sync): merge upstream $TAG" - { - echo "Merging upstream $TAG into main is clean but build or tests failed." - echo - echo "Failed run: $RUN_URL" - echo - echo "Fix on this branch, then merge it into main." - } > "$RUNNER_TEMP/pr-body.txt" - existing="$(gh pr list --state open --head "$branch" --json number --jq 'length')" - if [ "$existing" = "0" ]; then - gh pr create --base main --head "$branch" --title "$title" \ - --body-file "$RUNNER_TEMP/pr-body.txt" - else - echo "Open sync PR already exists" - fi - exit 1 - + run: mise run github:workflows:trogonstack-sync:publish-pull-request - name: Push main and build image if: steps.tag.outputs.up_to_date == 'false' && steps.verify.outcome == 'success' env: TAG: ${{ steps.tag.outputs.tag }} - run: | - # GITHUB_TOKEN cannot push commits that touch .github/workflows, and - # upstream changes those every month or so. Say so in an issue - # instead of leaving a bare red run. - if ! git push origin HEAD:main; then - title="Upstream sync needs a manual push: $TAG" - if git diff --quiet HEAD^1 HEAD -- .github/workflows; then - reason="The push to main was rejected." - else - reason="The merge changes .github/workflows, which GITHUB_TOKEN may not push." - fi - printf '%s\n\nRun: %s\n\nPush it locally with: git merge %s, then git push origin main.\n' \ - "$reason" "$RUN_URL" "$TAG" > "$RUNNER_TEMP/push-body.txt" - existing="$(gh issue list --state open --limit 200 --json title \ - --jq "[.[] | select(.title == \"$title\")] | length")" - if [ "$existing" = "0" ]; then - gh issue create --title "$title" --body-file "$RUNNER_TEMP/push-body.txt" - fi - exit 1 - fi - # Pushes made with GITHUB_TOKEN do not trigger workflows, but - # workflow_dispatch does, so start the image build explicitly. - gh workflow run trogonstack-image.yml --ref main - echo "Merged upstream $TAG into main and dispatched the image build" >> "$GITHUB_STEP_SUMMARY" + run: mise run github:workflows:trogonstack-sync:push-main + # Main may hold fork-only commits that were never released. The release + # workflow is idempotent, so run it for HEAD when it has an image, and + # build and release that commit when it has none. + - name: Release current main + if: steps.tag.outputs.up_to_date == 'true' + env: + REGISTRY_TOKEN: ${{ github.token }} + ACTOR: ${{ github.actor }} + run: mise run github:workflows:trogonstack-sync:release-current diff --git a/mise.toml b/mise.toml new file mode 100644 index 00000000000..f42b6b3ce4f --- /dev/null +++ b/mise.toml @@ -0,0 +1,4 @@ +# Tooling for the trogonstack-* workflows only. Upstream's Go build does not +# read this file. Tasks live in .config/mise/tasks (ADR 6647967335). +[tools] +"aqua:google/go-containerregistry" = "0.22.1"