name: Публикация пре-релиза (ручная) run-name: Publish ${{ inputs.tag }} on: workflow_dispatch: inputs: tag: description: "Exact prerelease tag, for example v1.61.0-beta.4" required: true type: string permissions: contents: write actions: read concurrency: group: publish-prerelease-${{ inputs.tag }} cancel-in-progress: false jobs: gate: name: "Гейт: зелёная Проверка и релизный контракт" runs-on: ubuntu-latest outputs: sha: ${{ steps.candidate.outputs.sha }} tag: ${{ steps.candidate.outputs.tag }} steps: - uses: actions/checkout@v7 with: ref: ${{ github.sha }} fetch-depth: 0 - uses: actions/setup-node@v7 with: { node-version: 22 } - name: Pin the current dev candidate id: candidate env: TAG: ${{ inputs.tag }} REF_NAME: ${{ github.ref_name }} run: | set -euo pipefail test "$REF_NAME" = "dev" || { echo "::error::Prereleases must be dispatched from the dev branch, got $REF_NAME" exit 1 } SHA=$(git rev-parse HEAD) git fetch origin dev test "$(git rev-parse origin/dev)" = "$SHA" || { echo "::error::The dispatched SHA is no longer the origin/dev tip" exit 1 } echo "sha=$SHA" >> "$GITHUB_OUTPUT" echo "tag=$TAG" >> "$GITHUB_OUTPUT" - name: Verify version, changelogs and bilingual release notes env: TAG: ${{ inputs.tag }} run: node scripts/release-contract.mjs "$TAG" --repo="$GITHUB_REPOSITORY" - name: Require green Validate for this exact SHA env: GH_TOKEN: ${{ github.token }} REPO: ${{ github.repository }} SHA: ${{ steps.candidate.outputs.sha }} run: node scripts/release-gate.mjs "$SHA" publish: name: Публикация тега и релиза needs: gate runs-on: ubuntu-latest outputs: url: ${{ steps.verify.outputs.url }} newly_published: ${{ steps.release.outputs.newly_published }} steps: - uses: actions/checkout@v7 with: ref: ${{ needs.gate.outputs.sha }} fetch-depth: 0 - uses: actions/setup-node@v7 with: { node-version: 22 } - name: Build and verify both release assets before publication env: TAG: ${{ needs.gate.outputs.tag }} run: | set -euo pipefail npm ci npm run build node scripts/bundle-tree.mjs dist custom_components/houseplan/frontend npm run bundle:budget VERSION=${TAG#v} grep -RFq "$VERSION" dist (cd custom_components/houseplan && zip -qr ../../houseplan.zip .) node scripts/verify-houseplan-zip.mjs houseplan.zip \ custom_components/houseplan/frontend "$VERSION" test -s dist/houseplan-card.js test -s houseplan.zip - name: Create or verify the annotated tag env: TAG: ${{ needs.gate.outputs.tag }} SHA: ${{ needs.gate.outputs.sha }} run: | set -euo pipefail REMOTE=$(git ls-remote --tags origin "refs/tags/$TAG" "refs/tags/$TAG^{}") if [ -n "$REMOTE" ]; then PEELED=$(printf '%s\n' "$REMOTE" | awk -v ref="refs/tags/$TAG^{}" '$2 == ref {print $1}') test -n "$PEELED" || { echo "::error::Existing remote tag $TAG is not annotated" exit 1 } test "$PEELED" = "$SHA" || { echo "::error::Existing tag $TAG points to $PEELED, expected $SHA" exit 1 } git fetch --force origin "refs/tags/$TAG:refs/tags/$TAG" test "$(git cat-file -t "refs/tags/$TAG")" = "tag" else git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git tag -a "$TAG" "$SHA" -m "$TAG" git push origin "$TAG" fi - name: Stage, verify and publish the prerelease id: release env: GH_TOKEN: ${{ github.token }} TAG: ${{ needs.gate.outputs.tag }} run: | set -euo pipefail if ! gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then gh release create "$TAG" --repo "$GITHUB_REPOSITORY" --verify-tag \ --draft --prerelease --title "$TAG" --notes-file docs/RELEASE-NOTES.md fi WAS_DRAFT=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json isDraft --jq .isDraft) echo "newly_published=$WAS_DRAFT" >> "$GITHUB_OUTPUT" gh release upload "$TAG" dist/houseplan-card.js houseplan.zip \ --repo "$GITHUB_REPOSITORY" --clobber RELEASE_JSON=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" \ --json tagName,isDraft,isPrerelease,assets,url) export RELEASE_JSON TAG node <<'NODE' const release = JSON.parse(process.env.RELEASE_JSON); if (release.tagName !== process.env.TAG) throw new Error('release tag mismatch'); const assets = new Map(release.assets.map((asset) => [asset.name, asset])); for (const name of ['houseplan-card.js', 'houseplan.zip']) { if (!(Number(assets.get(name)?.size) > 0)) throw new Error(`${name} is missing or empty`); } NODE gh release edit "$TAG" --repo "$GITHUB_REPOSITORY" --draft=false --prerelease \ --title "$TAG" --notes-file docs/RELEASE-NOTES.md - name: Verify the public release and assets id: verify env: GH_TOKEN: ${{ github.token }} TAG: ${{ needs.gate.outputs.tag }} SHA: ${{ needs.gate.outputs.sha }} run: | set -euo pipefail RELEASE_JSON=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" \ --json tagName,isDraft,isPrerelease,assets,url) export RELEASE_JSON TAG node <<'NODE' const release = JSON.parse(process.env.RELEASE_JSON); if (release.tagName !== process.env.TAG || release.isDraft || !release.isPrerelease) throw new Error('release is not a public prerelease for the requested tag'); const assets = new Map(release.assets.map((asset) => [asset.name, asset])); for (const name of ['houseplan-card.js', 'houseplan.zip']) { if (!(Number(assets.get(name)?.size) > 0)) throw new Error(`${name} is missing or empty`); } NODE test "$(git rev-list -n 1 "$TAG")" = "$SHA" URL=$(node -p "JSON.parse(process.env.RELEASE_JSON).url") echo "url=$URL" >> "$GITHUB_OUTPUT" printf '### Published %s\n\n- exact SHA: `%s`\n- [GitHub prerelease](%s)\n- assets: `houseplan-card.js`, `houseplan.zip`\n' \ "$TAG" "$SHA" "$URL" >> "$GITHUB_STEP_SUMMARY" - name: Verify HACS prerelease discovery order uses: actions/github-script@v9 env: EXPECTED_TAG: ${{ needs.gate.outputs.tag }} with: script: | const releases = await github.paginate(github.rest.repos.listReleases, { owner: context.repo.owner, repo: context.repo.repo, per_page: 100, }); const first = releases.find((release) => release.prerelease && !release.draft); if (first?.tag_name !== process.env.EXPECTED_TAG) { core.setFailed( `HACS prerelease discovery is stale: ${first?.tag_name ?? 'none'} precedes ` + process.env.EXPECTED_TAG, ); } # PROCESS.md 10.2 item 10: closing issues and stripping status labels happens # because a beta was published, not because someone remembered to do it. The # manual step was skipped twice, and both times it broke the invariant that a # closed issue carries no status label — the one thing `verify` relies on. # # A manual step after a successful release is the worst kind: by the time it is # due, the work already looks finished, which is exactly why it gets forgotten. close-merged: name: Закрытие вошедших issue needs: [gate, publish] if: ${{ needs.publish.outputs.newly_published == 'true' }} runs-on: ubuntu-latest permissions: contents: read # Deliberately the stock token, not a PAT: events caused by GITHUB_TOKEN do # not start workflows, so removing the label cannot wake the review # pipeline. A PAT here would build a cascade out of a bookkeeping step. issues: write steps: - name: Close the S8-merged queue and strip status labels env: GH_TOKEN: ${{ github.token }} REPO: ${{ github.repository }} TAG: ${{ needs.gate.outputs.tag }} URL: ${{ needs.publish.outputs.url }} run: | set -euo pipefail # Only the owner's issues take part in the process; issues filed by # anyone else never carry status labels and are not ours to close. numbers=$(gh issue list --repo "$REPO" --state open --label S8-merged \ --author Matysh --limit 100 --json number --jq '.[].number') if [ -z "$numbers" ]; then echo "the S8-merged queue is empty, nothing to close" else for n in $numbers; do gh issue comment "$n" --repo "$REPO" \ --body "Выпущено в \`$TAG\` · [релиз]($URL)" # Label first, then close. If the run dies between the two steps an # open issue without a status is visible and fixable in the flow; # the reverse order would recreate the exact breakage this job is # here to prevent. gh issue edit "$n" --repo "$REPO" --remove-label S8-merged gh issue close "$n" --repo "$REPO" --reason completed echo "closed #$n" done fi # Targeted at the defect that actually recurs, not at the invariant in # general: no closed issue may still carry S8-merged. leftover=$(gh issue list --repo "$REPO" --state closed --label S8-merged \ --limit 100 --json number --jq 'length') test "$leftover" = "0" || { echo "::error::$leftover closed issues still carry S8-merged" exit 1 } announce: name: Комментарий о публикации needs: [gate, publish] if: ${{ needs.publish.outputs.newly_published == 'true' }} uses: ./.github/workflows/announce.yml with: reusable: true tag: ${{ needs.gate.outputs.tag }} release_name: ${{ needs.gate.outputs.tag }} url: ${{ needs.publish.outputs.url }} prerelease: true ref: ${{ needs.gate.outputs.tag }} secrets: inherit