Weekly Draft Pre-release #46
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: "Weekly Draft Pre-release" | |
| # Cuts a pre-release every Tuesday from v1.0-dev, versioned | |
| # v<core>-weekly.<YYYYMMDD> (core read live from Cargo.toml), skipping when | |
| # there are no new commits since the last weekly tag or when CI is red. | |
| # Posts a Slack notification linking to the release page. | |
| # | |
| # DRAFT vs PUBLISHED: the release is built as a draft either way, and the | |
| # `finalize` job publishes it at the end of a SCHEDULED run once every build | |
| # leg is green and every artifact is attached. A `workflow_dispatch` run | |
| # leaves it a draft for the person who asked for it to review. Publishing | |
| # always keeps the prerelease flag, so a weekly never becomes "Latest". | |
| # | |
| # SECURITY NOTE: the `tag` job pushes with the default GITHUB_TOKEN only, | |
| # never a PAT. GitHub suppresses new workflow runs triggered by the default | |
| # token, which is what keeps release.yml's `push: tags: v*` trigger dormant | |
| # for this tag (it matches `v*`). Pushing this tag with a PAT would fire a | |
| # duplicate NON-draft prerelease build. Do not "fix" this by swapping in a PAT. | |
| on: | |
| schedule: | |
| - cron: "30 3 * * 2" # Every Tuesday at 03:30 UTC | |
| workflow_dispatch: | |
| inputs: | |
| force: | |
| description: "Re-cut / update today's draft even if it already exists or no new commits landed" | |
| type: boolean | |
| default: false | |
| version: | |
| description: "Custom semver to use instead of the auto-derived v<core>-weekly.<date> (optional)" | |
| type: string | |
| default: "" | |
| concurrency: | |
| group: weekly-build | |
| cancel-in-progress: false # weekly cadence: never interrupt a half-cut release | |
| permissions: {} | |
| jobs: | |
| gate: | |
| name: Determine whether to release | |
| runs-on: ubuntu-24.04 | |
| permissions: | |
| contents: read | |
| actions: read | |
| outputs: | |
| should_release: ${{ steps.decide.outputs.should_release }} | |
| ci_red: ${{ steps.decide.outputs.ci_red }} | |
| reason: ${{ steps.decide.outputs.reason }} | |
| version: ${{ steps.decide.outputs.version }} | |
| tag: ${{ steps.decide.outputs.tag }} | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| steps: | |
| - name: Check out code | |
| uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| - name: Decide | |
| id: decide | |
| env: | |
| FORCE: ${{ inputs.force || false }} | |
| MANUAL_VERSION: ${{ inputs.version }} | |
| REF_NAME: ${{ github.ref_name }} | |
| run: | | |
| set -euo pipefail | |
| # CI-red is a HARD block — never overridable by force or version | |
| # (it's a safety net, not a convenience toggle). Treated as "latest | |
| # *completed* run of each CI workflow on v1.0-dev" rather than | |
| # HEAD-sha check-runs, because tests.yml/clippy.yml are path-filtered | |
| # and a docs-only HEAD would otherwise have zero check-runs and read | |
| # as falsely green. A workflow that has never run is treated as | |
| # absent, not red. | |
| red=false | |
| for wf in tests.yml clippy.yml; do | |
| concl=$(gh run list --workflow "$wf" --branch "$REF_NAME" --status completed \ | |
| --limit 1 --json conclusion --jq '.[0].conclusion // ""') | |
| case "$concl" in | |
| failure | cancelled | timed_out | startup_failure) red=true ;; | |
| esac | |
| done | |
| if [ "$red" = true ]; then | |
| { | |
| echo "should_release=false" | |
| echo "ci_red=true" | |
| echo "reason=ci_red" | |
| } >>"$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| echo "ci_red=false" >>"$GITHUB_OUTPUT" | |
| # Version / tag derivation | |
| if [ -n "$MANUAL_VERSION" ]; then | |
| # Fail-closed on malformed human input — it flows into `git tag`. | |
| if ! printf '%s' "$MANUAL_VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$'; then | |
| echo "::error::Invalid 'version' input '$MANUAL_VERSION' — must be semver, e.g. 1.0.0-weekly.20260715 or 1.2.0-rc.1" | |
| exit 1 | |
| fi | |
| VERSION="$MANUAL_VERSION" | |
| else | |
| CORE=$(grep -m1 '^version = ' Cargo.toml | sed -E 's/.*"([^"]+)".*/\1/; s/[-+].*//') # 1.0.0-dev -> 1.0.0 | |
| VERSION="${CORE}-weekly.$(date -u +%Y%m%d)+$(git rev-parse --short=9 HEAD)" | |
| fi | |
| TAG="v${VERSION%%+*}" # strip +buildmetadata for the git ref | |
| # Already-released / no-new-commits — bypassed ONLY by force. | |
| # A supplied `version` bypasses neither of these gates. | |
| git fetch --tags --quiet | |
| if [ "$FORCE" != "true" ]; then | |
| if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then | |
| { | |
| echo "should_release=false" | |
| echo "reason=already_released" | |
| } >>"$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| LAST=$(gh api "repos/$GITHUB_REPOSITORY/releases" --paginate \ | |
| --jq '[.[] | select((.body // "") | test("<!-- weekly-version:"))] | sort_by(.created_at) | reverse | .[0].tag_name // empty') | |
| if [ -n "$LAST" ] && git rev-parse -q --verify "refs/tags/$LAST" >/dev/null && [ "$(git rev-list --count "${LAST}..HEAD")" -eq 0 ]; then | |
| { | |
| echo "should_release=false" | |
| echo "reason=no_commits" | |
| } >>"$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| fi | |
| { | |
| echo "should_release=true" | |
| echo "reason=ok" | |
| echo "version=$VERSION" | |
| echo "tag=$TAG" | |
| } >>"$GITHUB_OUTPUT" | |
| tag: | |
| name: Push weekly tag | |
| needs: gate | |
| if: needs.gate.outputs.should_release == 'true' | |
| runs-on: ubuntu-24.04 | |
| permissions: | |
| contents: write | |
| outputs: | |
| tag: ${{ needs.gate.outputs.tag }} | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| TAG: ${{ needs.gate.outputs.tag }} | |
| steps: | |
| - name: Check out code | |
| uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| - name: Verify force-recut ownership | |
| run: | | |
| set -euo pipefail | |
| if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then | |
| # Never force-move a tag we don't own, and never delete other | |
| # releases before this check — otherwise force=true + a version | |
| # matching an existing official release (e.g. 0.9.3) would first | |
| # destroy the legitimate current weekly draft during cleanup | |
| # below, THEN correctly refuse the hijack — needless collateral | |
| # damage. Only proceed if a GH release for this tag exists, is | |
| # still a draft, and carries our own weekly-version marker in its | |
| # body (the same marker `finalize` writes) — i.e. it's provably | |
| # one of THIS workflow's own not-yet-published drafts, never | |
| # anything else. | |
| owned=$(gh api "repos/$GITHUB_REPOSITORY/releases" --paginate \ | |
| --jq ".[] | select(.tag_name == \"$TAG\" and .draft == true and ((.body // \"\") | test(\"<!-- weekly-version:\"))) | \"true\"" \ | |
| 2>/dev/null | head -n1 || echo "") | |
| if [ "$owned" != "true" ]; then | |
| echo "::error::Refusing force re-cut: tag $TAG already exists and is not one of this workflow's own draft releases. This would collide with an existing or already-published release — pick a different version." | |
| exit 1 | |
| fi | |
| echo "Tag $TAG already exists as our own draft — safe to force re-cut." | |
| fi | |
| - name: Delete stale weekly drafts | |
| run: | | |
| set -euo pipefail | |
| mapfile -t olds < <(gh api "repos/$GITHUB_REPOSITORY/releases" --paginate \ | |
| --jq '.[] | select(.draft == true and ((.body // "") | test("<!-- weekly-version:"))) | .tag_name') | |
| for old in "${olds[@]}"; do | |
| [ -z "$old" ] && continue | |
| [ "$old" = "$TAG" ] && continue # never touch today's tag (force re-cut) | |
| echo "Removing stale weekly draft $old" | |
| gh release delete "$old" --yes --cleanup-tag | |
| done | |
| - name: Push weekly tag | |
| run: | | |
| set -euo pipefail | |
| if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then | |
| # Ownership already verified by "Verify force-recut ownership" | |
| # above, before any stale-draft deletion ran. | |
| echo "Tag $TAG already exists as our own draft — force re-cut: moving it to the current commit." | |
| git config user.name "github-actions[bot]" | |
| git config user.email "41898282+github-actions[bot]@users.noreply.github.com" | |
| git tag -f -a "$TAG" -m "Weekly draft pre-release $TAG" HEAD | |
| git push --force origin "refs/tags/$TAG" | |
| exit 0 | |
| fi | |
| # Annotated tags need a committer identity; the runner's checkout | |
| # has none configured by default. Standard github-actions[bot] identity. | |
| git config user.name "github-actions[bot]" | |
| git config user.email "41898282+github-actions[bot]@users.noreply.github.com" | |
| # See the SECURITY NOTE at the top of this file: GITHUB_TOKEN only. | |
| # Use HEAD, not $GITHUB_SHA: default checkout resolves the triggering | |
| # ref, so HEAD is the commit this workflow is intentionally building. | |
| git tag -a "$TAG" -m "Weekly draft pre-release $TAG" HEAD | |
| git push origin "refs/tags/$TAG" | |
| release: | |
| name: Build platform artifacts | |
| needs: [gate, tag] | |
| uses: ./.github/workflows/release.yml | |
| secrets: inherit | |
| permissions: | |
| contents: write | |
| id-token: write | |
| attestations: write | |
| with: | |
| tag: ${{ needs.gate.outputs.tag }} | |
| draft: true | |
| version_override: ${{ needs.gate.outputs.version }} | |
| flatpak: | |
| name: Build Flatpak artifacts | |
| needs: [gate, tag] | |
| uses: ./.github/workflows/flatpak.yml | |
| secrets: inherit | |
| # NOTE: flatpak.yml's own `build-flatpak` job hard-codes | |
| # `permissions: { contents: write }` (needed for its `release:`-event | |
| # attach-to-release step). GitHub's nested-workflow permission capping is | |
| # NOT silent truncation — a nested job's own declared permissions must be | |
| # <= what the caller grants, or the whole workflow fails validation | |
| # ("Invalid workflow file", startup_failure) before any job runs. So this | |
| # must be `write` even though the write capability goes unused on this | |
| # workflow_call path (the attach step only fires on the real `release` | |
| # event, never on workflow_call). | |
| permissions: | |
| contents: write | |
| with: | |
| version_override: ${{ needs.gate.outputs.version }} | |
| finalize: | |
| name: Attach Flatpak bundles and fetch release URL | |
| needs: [gate, release, flatpak] | |
| if: success() | |
| runs-on: ubuntu-24.04 | |
| permissions: | |
| contents: write | |
| actions: read | |
| outputs: | |
| url: ${{ steps.upload.outputs.url }} | |
| # "true" only when this run actually published the release. Empty for a | |
| # manual cut left as a draft, and empty when publishing failed — both of | |
| # which leave a draft needing a human. | |
| published: ${{ steps.publish.outputs.published }} | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| TAG: ${{ needs.gate.outputs.tag }} | |
| steps: | |
| - name: Download Flatpak artifacts | |
| uses: actions/download-artifact@v4 | |
| with: | |
| pattern: "dash-evo-tool-linux-*-flatpak" | |
| merge-multiple: true | |
| path: fp | |
| - name: Upload Flatpak bundles and fetch release URL | |
| id: upload | |
| env: | |
| VERSION: ${{ needs.gate.outputs.version }} | |
| run: | | |
| set -euo pipefail | |
| gh release edit "$TAG" --notes "<!-- weekly-version: ${VERSION} -->" -R "$GITHUB_REPOSITORY" | |
| gh release upload "$TAG" fp/*.flatpak --clobber -R "$GITHUB_REPOSITORY" | |
| url=$(gh release view "$TAG" --json url --jq .url -R "$GITHUB_REPOSITORY") | |
| echo "url=$url" >>"$GITHUB_OUTPUT" | |
| # Publish the scheduled cut. Reaching this step already means every | |
| # build leg is green (`if: success()` on this job) and every artifact is | |
| # attached, so the draft state has nothing left to protect against and | |
| # only costs a manual click each week. | |
| # | |
| # Scheduled runs only. A `workflow_dispatch` cut stays a draft: someone | |
| # asked for that run by hand, usually to inspect or re-cut something, | |
| # and gets to decide when it ships. | |
| # | |
| # `--prerelease` is re-asserted rather than assumed. Publishing a | |
| # release not flagged prerelease would make it the repository's "Latest" | |
| # and hand weekly builds to everyone tracking stable; the flag is set at | |
| # creation, and this keeps a change there from silently promoting a | |
| # weekly. | |
| # `continue-on-error` is load-bearing, not politeness. `cleanup` deletes | |
| # the tag and release whenever `finalize` does not succeed. By this point | |
| # the release is COMPLETE — every artifact attached — and only its draft | |
| # flag is unset, so letting a failed publish fail the job would destroy a | |
| # good release over a cosmetic last step. Failing soft leaves a complete | |
| # draft that can be published by hand; `notify` reads the outcome below | |
| # and says which happened. | |
| - name: Publish the weekly pre-release | |
| id: publish | |
| if: github.event_name == 'schedule' | |
| continue-on-error: true | |
| run: | | |
| set -euo pipefail | |
| gh release edit "$TAG" --draft=false --prerelease -R "$GITHUB_REPOSITORY" | |
| echo "published=true" >>"$GITHUB_OUTPUT" | |
| echo "Published $TAG as a pre-release." | |
| # Restore the invariant: a weekly tag exists ⟺ a complete release exists. | |
| cleanup: | |
| name: Clean up orphaned tag on build failure | |
| needs: [gate, tag, release, flatpak, finalize] | |
| if: always() && needs.gate.outputs.should_release == 'true' && needs.tag.result == 'success' && needs.finalize.result != 'success' | |
| runs-on: ubuntu-24.04 | |
| permissions: | |
| contents: write | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| TAG: ${{ needs.gate.outputs.tag }} | |
| steps: | |
| - name: Delete orphaned weekly tag | |
| run: | | |
| set -euo pipefail | |
| if gh release view "$TAG" -R "$GITHUB_REPOSITORY" >/dev/null 2>&1; then | |
| gh release delete "$TAG" --yes --cleanup-tag -R "$GITHUB_REPOSITORY" | |
| else | |
| gh api --method DELETE "repos/$GITHUB_REPOSITORY/git/refs/tags/$TAG" | |
| fi | |
| notify: | |
| name: Slack notification | |
| needs: [gate, tag, release, flatpak, finalize, cleanup] | |
| if: always() && (needs.gate.outputs.should_release == 'true' || needs.gate.outputs.ci_red == 'true') | |
| runs-on: ubuntu-24.04 | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: Post Slack notification | |
| env: | |
| SLACK_WEBHOOK_URL: ${{ secrets.SLACK_CI_WEBHOOK_URL }} | |
| SHOULD_RELEASE: ${{ needs.gate.outputs.should_release }} | |
| CI_RED: ${{ needs.gate.outputs.ci_red }} | |
| VERSION: ${{ needs.gate.outputs.version }} | |
| RELEASE_URL: ${{ needs.finalize.outputs.url }} | |
| FINALIZE_RESULT: ${{ needs.finalize.result }} | |
| PUBLISHED: ${{ needs.finalize.outputs.published }} | |
| RELEASE_RESULT: ${{ needs.release.result }} | |
| FLATPAK_RESULT: ${{ needs.flatpak.result }} | |
| TAG_RESULT: ${{ needs.tag.result }} | |
| CLEANUP_RESULT: ${{ needs.cleanup.result }} | |
| run: | | |
| set -euo pipefail | |
| if [ -z "${SLACK_WEBHOOK_URL:-}" ]; then | |
| echo "::warning::SLACK_CI_WEBHOOK_URL not set, skipping notification" | |
| exit 0 | |
| fi | |
| REPO="${GITHUB_REPOSITORY}" | |
| RUN_URL="https://github.com/${REPO}/actions/runs/${GITHUB_RUN_ID}" | |
| if [ "$CI_RED" = "true" ]; then | |
| TEXT=":warning: *I, Claudius the Magnificent, decline to bless a broken week.* CI on \`${GITHUB_REF_NAME}\` is red — no weekly draft was cut. Mend the pipeline: <${RUN_URL}|the run>." | |
| elif [ "$SHOULD_RELEASE" = "true" ] && [ "$FINALIZE_RESULT" = "success" ] && [ "${PUBLISHED:-}" = "true" ]; then | |
| TEXT=":sparkles: *I, Claudius the Magnificent, have published another weekly for \`${REPO}\`.* Version \`${VERSION}\` is live as a pre-release, no assent required: <${RELEASE_URL}|the release page>." | |
| elif [ "$SHOULD_RELEASE" = "true" ] && [ "$FINALIZE_RESULT" = "success" ]; then | |
| TEXT=":sparkles: *I, Claudius the Magnificent, have sealed another weekly draft for \`${REPO}\`.* Version \`${VERSION}\` awaits your royal assent — undraft it here: <${RELEASE_URL}|the release page>." | |
| elif [ "$SHOULD_RELEASE" = "true" ] && [ "$TAG_RESULT" = "success" ] && [ "$CLEANUP_RESULT" = "success" ]; then | |
| TEXT=":rotating_light: *Even I, Claudius the Magnificent, cannot forge a release from a broken build.* Version \`${VERSION}\` for \`${REPO}\` was tagged, yet the platform build faltered — I have swept the orphaned tag away so next week begins unblemished. Inspect the wreckage: <${RUN_URL}|the run>." | |
| elif [ "$SHOULD_RELEASE" = "true" ] && [ "$TAG_RESULT" = "success" ]; then | |
| TEXT=":rotating_light: *Even I, Claudius the Magnificent, cannot forge a release from a broken build, and I could not clean up after myself either.* Version \`${VERSION}\` for \`${REPO}\` was tagged, the platform build faltered, and automatic cleanup did not finish — a stray tag or draft release may remain. Manual sweep required: <${RUN_URL}|the run>." | |
| elif [ "$SHOULD_RELEASE" = "true" ]; then | |
| TEXT=":rotating_light: *Even I, Claudius the Magnificent, could not even lay down this week's tag.* Something broke for \`${REPO}\` before any build began. Inspect: <${RUN_URL}|the run>." | |
| else | |
| echo "Nothing to notify." | |
| exit 0 | |
| fi | |
| PAYLOAD=$(jq -n --arg text "$TEXT" '{text: $text}') | |
| curl -sf -X POST "$SLACK_WEBHOOK_URL" \ | |
| -H 'Content-Type: application/json' \ | |
| -d "$PAYLOAD" | |
| echo "Slack notification sent." |