Merge pull request #105 from Sea-Haven-Industries/feat/release-workflow-and-ios-ci

feat(workflows): add release and iOS CI reusable workflows, pass node-version on ci-python template
This commit is contained in:
Adam Moussa 2026-07-28 16:50:06 -04:00 • committed by GitHub
commit 0fd7ecf2a5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 643 additions and 0 deletions

301
.github/workflows/ci-mobile-ios.yaml vendored Normal file
View file

@ -0,0 +1,301 @@
name: CI — Mobile iOS
# Reusable CI counterpart to cd-mobile-ios.yaml. Verifies a React Native iOS
# app before merge: dependency install, typecheck, optional lint, optional unit
# tests, and a compile that is neither signed nor uploaded.
#
# Emits the single `ci / ci` status context required by the org branch-
# protection rulesets. As in ci-python-app.yaml, `ci` is an aggregator job
# gated on `needs`, so a caller job keyed `ci` reports `ci / ci` and that one
# context is red whenever any job below it failed.
#
# Input names mirror cd-mobile-ios.yaml wherever the same concept exists:
# node-version, ruby-version, working-directory, cache-dependency-path,
# fastlane-lane.
#
# Runner split. The JS checks run on ubuntu-latest; only the native compile
# runs on macOS, because only that step needs Xcode and CocoaPods. The macOS
# runner is billed at a multiple of the Linux rate, so putting `npm ci` +
# typecheck + tests on Linux keeps the expensive runner to the one job that
# genuinely requires it. `macos-26` matches cd-mobile-ios.yaml's runner, so CI
# compiles on the same Xcode image the deploy builds on.
#
# The compile is a `xcodebuild build`, not `archive` + `export`: it passes
# CODE_SIGNING_ALLOWED=NO / CODE_SIGNING_REQUIRED=NO / CODE_SIGN_IDENTITY="",
# and there is no App Store Connect or fastlane match step anywhere in this
# file. It therefore needs no signing certificates and no secrets, which is why
# `workflow_call` here declares none.
#
# run-lint and run-tests default to FALSE. The only current caller of
# cd-mobile-ios (proposal-system, working-directory `mobile`) declares exactly
# five npm scripts — start, ios, android, typecheck, postinstall — so a lint or
# test script cannot be assumed to exist. Turn them on per repo once the
# scripts are there.
#
# Caller example:
# jobs:
# ci:
# uses: Sea-Haven-Industries/.github/.github/workflows/ci-mobile-ios.yaml@<full-commit-sha> # main
# with:
# working-directory: mobile
# cache-dependency-path: mobile/package-lock.json
on:
workflow_call:
inputs:
node-version:
description: "Node.js version to use"
type: string
default: "24"
ruby-version:
description: "Ruby version for CocoaPods / Fastlane"
type: string
default: "3.3"
working-directory:
description: "Directory containing the mobile project"
type: string
default: "."
cache-dependency-path:
description: "Path to package-lock.json for npm cache"
type: string
default: "package-lock.json"
run-typecheck:
description: "Run the typecheck npm script"
type: boolean
default: true
typecheck-script:
description: "npm script name for the TypeScript check"
type: string
default: "typecheck"
run-lint:
description: "Run the lint npm script. The script must exist in package.json."
type: boolean
default: false
lint-script:
description: "npm script name for the linter"
type: string
default: "lint"
run-tests:
description: "Run the test npm script. The script must exist in package.json."
type: boolean
default: false
test-script:
description: "npm script name for the unit tests"
type: string
default: "test"
run-ios-build:
description: "Compile the iOS app without signing it"
type: boolean
default: true
fastlane-lane:
description: "Fastlane lane to run instead of xcodebuild. Empty means call xcodebuild directly. The lane must not sign or upload."
type: string
default: ""
xcode-workspace:
description: "Path to the .xcworkspace, relative to working-directory. Empty means auto-detect the one under ios/."
type: string
default: ""
xcode-scheme:
description: "Xcode scheme to build. Empty means the workspace file name without its extension."
type: string
default: ""
xcode-configuration:
description: "Xcode build configuration"
type: string
default: "Debug"
js-timeout-minutes:
description: "Timeout in minutes for the JavaScript checks job"
type: number
default: 15
ios-timeout-minutes:
description: "Timeout in minutes for the iOS compile job"
type: number
default: 45
# Read-only: this workflow builds and tests, it publishes nothing and assumes
# no cloud role. No `id-token` — nothing here mints an OIDC token.
permissions:
contents: read
jobs:
js:
runs-on: ubuntu-latest
timeout-minutes: ${{ inputs.js-timeout-minutes }}
# cancel-in-progress is TRUE: superseding a push should abandon the older
# CI run, which produces no external side effects. `github.job` is in the
# key so the three jobs here do not serialise against each other.
concurrency:
group: ci-mobile-ios-${{ github.workflow }}-${{ github.ref }}-${{ inputs.working-directory }}-${{ github.job }}
cancel-in-progress: true
defaults:
run:
working-directory: ${{ inputs.working-directory }}
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: ${{ inputs.node-version }}
cache: npm
cache-dependency-path: ${{ inputs.cache-dependency-path }}
- name: Install JS dependencies
run: npm ci
- name: Verify the requested npm scripts exist
env:
RUN_TYPECHECK: ${{ inputs.run-typecheck }}
TYPECHECK_SCRIPT: ${{ inputs.typecheck-script }}
RUN_LINT: ${{ inputs.run-lint }}
LINT_SCRIPT: ${{ inputs.lint-script }}
RUN_TESTS: ${{ inputs.run-tests }}
TEST_SCRIPT: ${{ inputs.test-script }}
run: |
node <<'NODE'
const { readFileSync } = require("node:fs");
const pkg = JSON.parse(readFileSync("package.json", "utf8"));
const wanted = [
[process.env.RUN_TYPECHECK, process.env.TYPECHECK_SCRIPT],
[process.env.RUN_LINT, process.env.LINT_SCRIPT],
[process.env.RUN_TESTS, process.env.TEST_SCRIPT],
];
const missing = wanted
.filter(([enabled, name]) => enabled === "true" && name)
.map(([, name]) => name)
.filter((name) => !pkg.scripts?.[name]);
if (missing.length > 0) {
console.error(`Enabled but missing from package.json scripts: ${missing.join(", ")}`);
process.exit(1);
}
console.log("All enabled npm scripts are present.");
NODE
- name: Typecheck
if: ${{ inputs.run-typecheck }}
env:
TYPECHECK_SCRIPT: ${{ inputs.typecheck-script }}
run: npm run "${TYPECHECK_SCRIPT}"
- name: Lint
if: ${{ inputs.run-lint }}
env:
LINT_SCRIPT: ${{ inputs.lint-script }}
run: npm run "${LINT_SCRIPT}"
- name: Unit tests
if: ${{ inputs.run-tests }}
env:
TEST_SCRIPT: ${{ inputs.test-script }}
run: npm run "${TEST_SCRIPT}"
ios-build:
if: ${{ inputs.run-ios-build }}
runs-on: macos-26
timeout-minutes: ${{ inputs.ios-timeout-minutes }}
concurrency:
group: ci-mobile-ios-${{ github.workflow }}-${{ github.ref }}-${{ inputs.working-directory }}-${{ github.job }}
cancel-in-progress: true
defaults:
run:
working-directory: ${{ inputs.working-directory }}
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: ${{ inputs.node-version }}
cache: npm
cache-dependency-path: ${{ inputs.cache-dependency-path }}
- uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1
with:
ruby-version: ${{ inputs.ruby-version }}
bundler-cache: true
working-directory: ${{ inputs.working-directory }}
- name: Install JS dependencies
run: npm ci
- name: Install CocoaPods
run: bundle exec pod install --project-directory=ios
- name: Point the Xcode build phase at the runner's node
# React Native's "Bundle React Native code and images" build phase
# resolves node through .xcode.env.local. cd-mobile-ios.yaml gets this
# from the repo's Fastfile; the xcodebuild path below has no Fastfile
# step, so write it here.
run: |
set -euo pipefail
node_path="$(command -v node)"
printf 'export NODE_BINARY=%s\n' "${node_path}" > ios/.xcode.env.local
echo "NODE_BINARY set to ${node_path}"
- name: Build without signing
if: ${{ inputs.fastlane-lane == '' }}
env:
XCODE_WORKSPACE: ${{ inputs.xcode-workspace }}
XCODE_SCHEME: ${{ inputs.xcode-scheme }}
XCODE_CONFIGURATION: ${{ inputs.xcode-configuration }}
run: |
set -euo pipefail
workspace="${XCODE_WORKSPACE}"
if [ -z "${workspace}" ]; then
workspace="$(find ios -maxdepth 1 -name '*.xcworkspace' | head -n 1)"
fi
if [ -z "${workspace}" ] || [ ! -d "${workspace}" ]; then
echo "::error::No .xcworkspace found. Set xcode-workspace explicitly."
exit 1
fi
scheme="${XCODE_SCHEME}"
if [ -z "${scheme}" ]; then
scheme="$(basename "${workspace}" .xcworkspace)"
fi
echo "Building workspace ${workspace}, scheme ${scheme}, configuration ${XCODE_CONFIGURATION}."
# `build`, not `archive`: no .ipa is produced and nothing is exported.
# The three CODE_SIGN* settings turn signing off entirely, so this
# needs no certificates and cannot upload anything.
xcodebuild build \
-workspace "${workspace}" \
-scheme "${scheme}" \
-configuration "${XCODE_CONFIGURATION}" \
-destination 'generic/platform=iOS' \
-derivedDataPath "${RUNNER_TEMP}/DerivedData" \
CODE_SIGNING_ALLOWED=NO \
CODE_SIGNING_REQUIRED=NO \
CODE_SIGN_IDENTITY=""
- name: Build via Fastlane
if: ${{ inputs.fastlane-lane != '' }}
env:
FASTLANE_LANE: ${{ inputs.fastlane-lane }}
run: |
set -euo pipefail
# Deliberately word-split: `fastlane-lane` carries a platform and a
# lane ("ios build") that fastlane expects as two separate argv
# entries, exactly as cd-mobile-ios.yaml passes it. Quoting would
# send one argument and fastlane would not find the lane.
# shellcheck disable=SC2086
bundle exec fastlane ${FASTLANE_LANE}
ci:
# Aggregator — keyed `ci` so a caller job keyed `ci` reports `ci / ci`.
needs: [js, ios-build]
if: always()
runs-on: ubuntu-latest
concurrency:
group: ci-mobile-ios-${{ github.workflow }}-${{ github.ref }}-${{ inputs.working-directory }}-${{ github.job }}
cancel-in-progress: true
steps:
- name: Require all jobs to have succeeded
run: |
if [ "${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}" = "true" ]; then
echo "A required CI job failed or was cancelled."
exit 1
fi
echo "All CI jobs passed."

279
.github/workflows/release.yaml vendored Normal file
View file

@ -0,0 +1,279 @@
name: Release — Tag and GitHub Release
# Reusable release workflow: creates an annotated git tag at a commit and
# publishes a GitHub Release pointing at it.
#
# Version derivation is an INPUT, not read from a manifest and not computed.
# That follows what the org's repos actually contain:
# - 3 of 23 non-archived repos carry any tag at all; every existing tag is
# `vMAJOR.MINOR.PATCH`, which is why `tag-prefix` defaults to "v".
# - `package.json` "version" is not maintained as a release marker where it
# exists: seahaven-door-unlock-api sits at "1.0.0" while its tags reach
# v3.0.0, and payments-dashboard sits at "1.0.0" with no tags at all.
# - No repo has a VERSION file, and the repos that tag are Python/SAM,
# TypeScript/CDK and Python-desktop, so there is no one manifest to read.
# - The single repo that derives a version from a file
# (afterhours-shift-manager, from CHANGELOG.md) does it with two
# repo-local parser scripts under `scripts/`, which a reusable workflow
# cannot assume exist.
# An explicit input is therefore the only derivation that works unchanged for
# every repo here. A repo that does maintain a machine-readable version can
# still pass it: `version: ${{ needs.x.outputs.version }}`.
#
# Re-running on a version that is already released is a no-op, not a failure:
# the tag and the Release are both checked first, and either one being present
# skips every mutating step. This mirrors afterhours-shift-manager's release
# job, which likewise no-ops when the Release already exists.
#
# Safe in a repo with no releases yet: the "previous tag" lookup tolerates zero
# tags, and `gh release create --generate-notes` falls back to the full commit
# history when there is no earlier release to diff against.
#
# Caller example:
# jobs:
# release:
# uses: Sea-Haven-Industries/.github/.github/workflows/release.yaml@<full-commit-sha> # main
# with:
# version: ${{ inputs.version }}
on:
workflow_call:
inputs:
version:
description: 'Version to release, e.g. "1.4.0". A leading "v" is accepted and stripped.'
type: string
required: true
tag-prefix:
description: "Prefix placed in front of the version to form the tag name"
type: string
default: "v"
target:
description: "Commit-ish to tag. Empty means the commit the workflow was triggered on."
type: string
default: ""
notes-file:
description: "Path to a file whose contents become the release notes. Empty means use generate-notes."
type: string
default: ""
generate-notes:
description: "Let GitHub generate release notes from commits when notes-file is empty"
type: boolean
default: true
title:
description: "Release title. Empty means use the tag name."
type: string
default: ""
draft:
description: "Publish the Release as a draft"
type: boolean
default: false
prerelease:
description: "Mark the Release as a prerelease"
type: boolean
default: false
timeout-minutes:
description: "Job timeout in minutes"
type: number
default: 10
outputs:
tag:
description: "The tag name that was created, or that already existed"
value: ${{ jobs.release.outputs.tag }}
version:
description: "The normalised version, without the tag prefix"
value: ${{ jobs.release.outputs.version }}
released:
description: '"true" when this run created the tag and Release, "false" when it skipped'
value: ${{ jobs.release.outputs.released }}
url:
description: "URL of the Release this run created. Empty when the run skipped."
value: ${{ jobs.release.outputs.url }}
# Only `contents: write` — needed to push the tag and publish the Release.
# Nothing here mints an OIDC token, so `id-token` is deliberately not granted.
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
timeout-minutes: ${{ inputs.timeout-minutes }}
# Serialise per tag so two runs cannot race to create the same release.
# version is required and tag-prefix always defaults, so the group is never
# empty. cancel-in-progress is FALSE on purpose: unlike CI, aborting midway
# can leave a pushed tag with no Release attached to it.
concurrency:
group: release-${{ github.repository }}-${{ inputs.tag-prefix }}${{ inputs.version }}
cancel-in-progress: false
outputs:
tag: ${{ steps.resolve.outputs.tag }}
version: ${{ steps.resolve.outputs.version }}
released: ${{ steps.publish.outputs.released || 'false' }}
url: ${{ steps.publish.outputs.url }}
steps:
- uses: actions/checkout@v7
with:
# Full history + tags: the existing-tag guard reads local refs.
fetch-depth: 0
fetch-tags: true
ref: ${{ inputs.target }}
- name: Resolve and validate version
id: resolve
# Inputs are passed through env, never interpolated into the script
# body, so a caller cannot inject shell into this step.
env:
RAW_VERSION: ${{ inputs.version }}
TAG_PREFIX: ${{ inputs.tag-prefix }}
run: |
set -euo pipefail
version="${RAW_VERSION#v}"
if ! printf '%s' "${version}" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$'; then
echo "::error::version '${RAW_VERSION}' is not MAJOR.MINOR.PATCH with an optional -prerelease/+build suffix."
exit 1
fi
tag="${TAG_PREFIX}${version}"
{
echo "version=${version}"
echo "tag=${tag}"
} >> "${GITHUB_OUTPUT}"
echo "Resolved ${RAW_VERSION} to tag ${tag}."
- name: Check whether the tag or Release already exists
id: guard
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ steps.resolve.outputs.tag }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
skip=false
reason=""
if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then
skip=true
reason="tag ${TAG} already exists locally"
elif [ -n "$(git ls-remote --tags origin "refs/tags/${TAG}")" ]; then
skip=true
reason="tag ${TAG} already exists on the remote"
elif gh release view "${TAG}" --repo "${REPO}" >/dev/null 2>&1; then
skip=true
reason="a Release for ${TAG} is already published"
fi
echo "skip=${skip}" >> "${GITHUB_OUTPUT}"
if [ "${skip}" = "true" ]; then
echo "::notice::Skipping — ${reason}."
else
echo "No existing tag or Release for ${TAG}."
fi
- name: Report the previous tag
if: ${{ steps.guard.outputs.skip == 'false' }}
run: |
set -euo pipefail
previous="$(git tag -l --sort=-v:refname | head -n 1)"
if [ -z "${previous}" ]; then
echo "No previous tag in this repository — this is the first release."
else
echo "Previous tag: ${previous}"
fi
- name: Resolve release notes
id: notes
if: ${{ steps.guard.outputs.skip == 'false' }}
env:
NOTES_FILE: ${{ inputs.notes-file }}
GENERATE_NOTES: ${{ inputs.generate-notes }}
run: |
set -euo pipefail
if [ -n "${NOTES_FILE}" ]; then
if [ ! -f "${NOTES_FILE}" ]; then
echo "::error::notes-file '${NOTES_FILE}' does not exist."
exit 1
fi
echo "mode=file" >> "${GITHUB_OUTPUT}"
echo "Using release notes from ${NOTES_FILE}."
elif [ "${GENERATE_NOTES}" = "true" ]; then
echo "mode=generate" >> "${GITHUB_OUTPUT}"
echo "Release notes will be generated from commit history."
else
echo "mode=empty" >> "${GITHUB_OUTPUT}"
echo "Publishing with empty release notes."
fi
- name: Create and push the annotated tag
if: ${{ steps.guard.outputs.skip == 'false' }}
env:
TAG: ${{ steps.resolve.outputs.tag }}
run: |
set -euo pipefail
# -a makes this an annotated tag: it is a real tag object carrying a
# tagger, a date and a message, unlike a lightweight ref.
git -c user.name='github-actions[bot]' \
-c user.email='41898282+github-actions[bot]@users.noreply.github.com' \
tag -a "${TAG}" -m "${TAG}" "${GITHUB_SHA}"
git push origin "refs/tags/${TAG}"
echo "Pushed annotated tag ${TAG} at ${GITHUB_SHA}."
- name: Publish the GitHub Release
id: publish
if: ${{ steps.guard.outputs.skip == 'false' }}
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
TAG: ${{ steps.resolve.outputs.tag }}
TITLE: ${{ inputs.title }}
NOTES_MODE: ${{ steps.notes.outputs.mode }}
NOTES_FILE: ${{ inputs.notes-file }}
DRAFT: ${{ inputs.draft }}
PRERELEASE: ${{ inputs.prerelease }}
run: |
set -euo pipefail
args=(release create "${TAG}" --repo "${REPO}" --verify-tag)
args+=(--title "${TITLE:-${TAG}}")
case "${NOTES_MODE}" in
file) args+=(--notes-file "${NOTES_FILE}") ;;
generate) args+=(--generate-notes) ;;
*) args+=(--notes "") ;;
esac
if [ "${DRAFT}" = "true" ]; then
args+=(--draft)
fi
if [ "${PRERELEASE}" = "true" ]; then
args+=(--prerelease)
fi
gh "${args[@]}"
url="$(gh release view "${TAG}" --repo "${REPO}" --json url --jq .url)"
{
echo "released=true"
echo "url=${url}"
} >> "${GITHUB_OUTPUT}"
echo "Published ${url}"
- name: Report a skipped run
if: ${{ steps.guard.outputs.skip == 'true' }}
env:
TAG: ${{ steps.resolve.outputs.tag }}
run: echo "${TAG} was already released. Nothing to do."

View file

@ -0,0 +1,7 @@
{
"name": "Sea Haven — CI (Mobile / iOS)",
"description": "Runs npm ci, typecheck, optional lint and optional unit tests on Linux, then compiles the iOS app on macOS with signing disabled and no upload, via the org reusable ci-mobile-ios workflow. The pre-merge counterpart to the iOS TestFlight deploy.",
"iconName": "octicon-checklist",
"categories": ["Mobile", "TypeScript", "JavaScript", "Continuous integration"],
"filePatterns": ["metro\\.config\\.[cm]?js$", "app\\.json$", "Podfile$"]
}

View file

@ -0,0 +1,21 @@
name: CI (Mobile / iOS)
on:
pull_request:
branches: [main]
jobs:
ci:
# Job id MUST stay `ci`: the reusable's aggregator job is also `ci`, so the
# check context resolves to the required `ci / ci`.
uses: Sea-Haven-Industries/.github/.github/workflows/ci-mobile-ios.yaml@9389e51c10c506caa55f204527452a9e29b0e438 # main
with:
# Matches local dev (Node 24 / npm 11, lockfileVersion 3). This is also
# the reusable workflow's default — passed explicitly to pin against drift.
node-version: "24"
# Set these two to the app directory when the mobile project is not at
# the repo root, matching the values passed to cd-mobile-ios.
working-directory: "."
cache-dependency-path: "package-lock.json"
# Off by default: enable once package.json actually declares the script.
run-lint: false
run-tests: false

View file

@ -8,3 +8,7 @@ jobs:
uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-sam.yaml@555d07c3a240689a81668026787eba089df4c975 # main
with:
run-tests: true
# ci-python-sam.yaml declares a `node-version` input (default "24") that
# feeds the CDK CLI when run-cdk-synth is enabled. Passed explicitly so
# the pin does not drift from local dev (Node 24 / npm 11).
node-version: "24"

View file

@ -0,0 +1,7 @@
{
"name": "Sea Haven — Release (tag + GitHub Release)",
"description": "Creates an annotated v-prefixed git tag and publishes a GitHub Release for a version supplied on manual dispatch, using the org reusable release workflow. Re-running on a version that is already tagged or released is a no-op.",
"iconName": "octicon-tag",
"categories": ["Deployment", "Utilities"],
"filePatterns": ["README\\.md$"]
}

View file

@ -0,0 +1,24 @@
name: Release
on:
workflow_dispatch:
inputs:
version:
description: 'Version to release, e.g. 1.4.0 (a leading "v" is accepted)'
type: string
required: true
permissions:
# The reusable workflow pushes an annotated tag and publishes a Release.
contents: write
jobs:
release:
uses: Sea-Haven-Industries/.github/.github/workflows/release.yaml@9389e51c10c506caa55f204527452a9e29b0e438 # main
with:
version: ${{ inputs.version }}
# Tags in this org are v-prefixed MAJOR.MINOR.PATCH; "v" is the default
# and is passed explicitly so a repo can see what it is producing.
tag-prefix: "v"
# Notes come from GitHub's commit-history generator. Set notes-file
# instead to publish the contents of a file as the release notes.
generate-notes: true