Skip to content

Weekly Draft Pre-release #46

Weekly Draft Pre-release

Weekly Draft Pre-release #46

Workflow file for this run

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."