diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml deleted file mode 100644 index 96ba594..0000000 --- a/.gitlab-ci.yml +++ /dev/null @@ -1,300 +0,0 @@ -# You can override the included template(s) by including variable overrides -# SAST customization: https://docs.gitlab.com/ee/user/application_security/sast/#customizing-the-sast-settings -# Secret Detection customization: https://docs.gitlab.com/user/application_security/secret_detection/pipeline/configure -# Dependency Scanning customization: https://docs.gitlab.com/ee/user/application_security/dependency_scanning/#customizing-the-dependency-scanning-settings -# Container Scanning customization: https://docs.gitlab.com/ee/user/application_security/container_scanning/#customizing-the-container-scanning-settings -# Note that environment variables can be set in several places -# See https://docs.gitlab.com/ee/ci/variables/#cicd-variable-precedence - -# ASH security scanning component -# Pinned to v3.0.5 — the internal `code.aws.dev` mirror has its own version -# line (v3.0.x) that does not track upstream awslabs/automated-security-helper. -# v3.0.5 (~2026-04-23) is the version against which our existing -# `cyclonedx: enabled: false` suppression in .ash/ash.yaml was added (2026-04-21) -# and verified working. v3.0.6 (~2026-05-16) appears to backport the upstream -# regression where the new `gitlab-cyclonedx` reporter emits a minimal "empty" -# SBOM that GitLab's parser rejects with "Required GitLab CycloneDX properties -# are missing" — our existing suppression does not cover that new reporter. -# Revisit when we wire up real GitLab dependency scanning, or when the mirror -# exposes a setting to disable the gitlab-cyclonedx reporter. -include: - - component: code.aws.dev/proserve/automated-security-helper/automated-security-helper/ash@v3.0.5 - - component: code.aws.dev/proserve/genaiid/other/candidate-reusable-assets/code-quality/acq@develop - -# Any publicly available python image -image: public.ecr.aws/docker/library/python:3.12-bookworm - -stages: - - test -# - securityScan # Inherited - - deploy - - release - -# AWS credential vendor: the internal code.aws.dev GitLab runner detects the -# AWS_CREDS_TARGET_ROLE variable and vendors short-lived STS credentials for that -# role into the job environment automatically — no id_tokens / assume-role block -# needed. This job verifies the role assumption works before relying on it. -# test: -# stage: test -# variables: -# AWS_CREDS_TARGET_ROLE: arn:aws:iam::369530416671:role/ip-dev-sdlc-GitLab -# AWS_DEFAULT_REGION: us-east-1 -# script: -# - aws sts get-caller-identity -# - aws s3 ls - -# GitLab Pages deployment job - must be named "pages" to trigger Pages deployment -# Builds Docusaurus documentation and publishes to https://.gitlab.io// -pages: - stage: deploy - needs: [] - image: public.ecr.aws/docker/library/node:22-bookworm - variables: - KUBERNETES_CPU_REQUEST: "2" - KUBERNETES_CPU_LIMIT: "2" - KUBERNETES_MEMORY_REQUEST: "4Gi" - KUBERNETES_MEMORY_LIMIT: "4Gi" - KUBERNETES_EPHEMERAL_STORAGE_REQUEST: "4Gi" - KUBERNETES_EPHEMERAL_STORAGE_LIMIT: "4Gi" - NODE_OPTIONS: "--max-old-space-size=3072" - before_script: - - corepack enable - # Internal docs (developer-docs/internal/) DO publish to GitLab Pages — - # this site is internal-only. They are filtered from the GitHub mirror - # by infra/scripts/github-push.sh EXCLUDE_PATHS, so they never reach - # GitHub or GitHub Pages. - - cd docs - # Uses the pnpm version pinned in docs/package.json "packageManager" - - pnpm install --frozen-lockfile - script: - - pnpm exec docusaurus build - - mv build ../public - artifacts: - paths: - - public - rules: - - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH - -# --------------------------------------------------------------------------- -# Stage 1 — internal release. Three jobs whose NAMES are the documentation: -# -# release:preview automatic on every push to main, non-blocking, creates -# nothing. Shows what the next release would be. -# release manual. Derives minor/patch from Conventional Commit -# history, tags origin, creates a GitLab Release. -# release:major manual, CONFIRM_VERSION-gated. The only way to reach a -# major version while the project is pre-1.0. -# -# The version is DERIVED from tags and commit history — there is no VERSION -# file. `git-cliff --bumped-version` is the single source of truth. -# -# Publishing to the public GitHub mirror is stage 2 (`publish:github:*`) and is -# deliberately NOT part of any job here. Cutting an internal release publishes -# nothing publicly. -# -# Why these are manual jobs on a BRANCH pipeline rather than tag-triggered: a -# tag pipeline cannot work here. `pages` requires $CI_COMMIT_BRANCH (unset on -# tag pipelines) and the release jobs require $CI_COMMIT_TAG == null. A manual -# job on a branch pipeline sidesteps both. -# --------------------------------------------------------------------------- -.release-base: - stage: release - image: public.ecr.aws/docker/library/python:3.12-bookworm - variables: - # Full clone required: version derivation walks history back to the newest - # release tag, and tag operations need the tag objects themselves. A shallow - # clone silently derives from the wrong base. - GIT_DEPTH: "0" - before_script: - # Pinned to match the version every mechanism in this pipeline was verified - # against. git-cliff is not in the base image. - - pip install --quiet --disable-pip-version-check 'git-cliff==2.10.1' - - git fetch origin --tags --force - - git config user.email "ci@code.aws.dev" - - git config user.name "GitLab CI" - -# Automatic, non-blocking preview. Prints the version that would be cut and the -# exact notes that would be published, and creates nothing. -# -# `allow_failure: true` keeps it non-blocking; the derived-equals-latest branch -# below keeps it GREEN when there is simply nothing to release. Both are needed -# — a job that is usually red is a job everyone learns to ignore, at which point -# it also hides the one time it means something. -release:preview: - extends: .release-base - rules: - - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH && $CI_COMMIT_TAG == null - allow_failure: true - - when: never - needs: [] - script: - - LATEST=$(git describe --tags --abbrev=0 2>/dev/null || echo "none") - - DERIVED=$(git-cliff --bumped-version) - # Quoted: a bare `: ` in a YAML plain scalar parses as a mapping key. - - 'echo "current release: $LATEST"' - - 'echo "would release: $DERIVED"' - - | - if [ "$DERIVED" = "$LATEST" ]; then - echo "" - echo "Nothing to release — no commits since $LATEST affect the version." - echo "Only conventional commits (feat:, fix:, ...) derive a new version." - exit 0 - fi - - echo "" - - echo "--- release notes for $DERIVED ---" - - git-cliff --unreleased --strip all - - echo "--- end release notes ---" - - echo "" - - echo "To cut this release, run the 'release' job. Nothing has been created." - -# Ordinary release: minor and patch. Cannot produce a major bump while pre-1.0 -# (cliff.toml sets breaking_always_bump_major = false), so reaching 1.0.0 -# requires the separately named release:major job. -release: - extends: .release-base - rules: - - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH && $CI_COMMIT_TAG == null - when: manual - allow_failure: false - - when: never - needs: - - job: ash - optional: true - script: - - TAG=$(git-cliff --bumped-version) - - LATEST=$(git describe --tags --abbrev=0 2>/dev/null || echo "none") - - | - if [ "$TAG" = "$LATEST" ]; then - echo "Nothing to release — derived version equals the current tag ($LATEST)." - echo "Only conventional commits (feat:, fix:, ...) derive a new version." - exit 1 - fi - - | - if git ls-remote --tags origin "refs/tags/$TAG" | grep -q "$TAG"; then - echo "error: tag $TAG already exists on origin — nothing to release" >&2 - exit 1 - fi - - echo "Releasing $TAG" - # Before tagging: assert tag hygiene and that $TAG is what history derives. - # Running it after would leave a wrong tag behind on failure. - - bash infra/scripts/release-check.sh "$TAG" - # --tag stamps the heading as "## [X.Y.Z] - ". Without it the notes are - # headed "## [Unreleased]", which is wrong on a tag that names a version — - # and the publish path's heading assertion looks for "## [". - - git-cliff --unreleased --tag "$TAG" --strip all > /tmp/release-notes.md - - git tag -a "$TAG" --cleanup=verbatim -F /tmp/release-notes.md - - git push origin "$TAG" - - bash infra/scripts/gitlab-release.sh "$TAG" /tmp/release-notes.md - - echo "" - - echo "Released $TAG internally. Nothing was published to GitHub." - - echo "To publish this tag publicly, run 'publish:github:plan' first." - -# Major release. Separately named and confirmation-gated because it is one of -# only two irreversible acts in the release path (the other is publishing). -# -# NOTE: breaking_always_bump_major = false is INERT once the major reaches 1. -# After 1.0.0 the ordinary `release` job will coin majors from feat!: commits -# and this job becomes decorative — accepted, since a feat!: commit genuinely -# should mean a major at that point. This job exists for the 0.x -> 1.0 -# transition, which is the transition it will actually be used for. -release:major: - extends: .release-base - rules: - - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH && $CI_COMMIT_TAG == null - when: manual - allow_failure: false - - when: never - needs: - - job: ash - optional: true - script: - - TAG=$(git-cliff --bump major --bumped-version) - # Gate BEFORE anything is created. A gate that fires after tagging is not a gate. - - | - if [ "${CONFIRM_VERSION:-}" != "$TAG" ]; then - echo "error: major release requires explicit confirmation" >&2 - echo "would cut: $TAG" >&2 - echo "hint: re-run this job with CONFIRM_VERSION=$TAG" >&2 - exit 1 - fi - - echo "Releasing MAJOR $TAG (confirmed)" - - bash infra/scripts/release-check.sh "$TAG" major - - git-cliff --bump major --unreleased --tag "$TAG" --strip all > /tmp/release-notes.md - - git tag -a "$TAG" --cleanup=verbatim -F /tmp/release-notes.md - - git push origin "$TAG" - - bash infra/scripts/gitlab-release.sh "$TAG" /tmp/release-notes.md - - echo "" - - echo "Released $TAG internally. Nothing was published to GitHub." - -# --------------------------------------------------------------------------- -# Stage 2 — public publish. Two jobs: -# -# publish:github:plan manual dry run. Prints TAG, FLOOR, the full notes body, -# the filtered-path report, and the exact CONFIRM_TAG -# value. Publishes nothing. -# publish:github manual, CONFIRM_TAG-gated. The one irreversible act in -# the release path — it force-pushes a public repository -# that has accepted pull requests. -# -# Publishing is deliberately separate from cutting an internal release, so an -# internal release can be cut without going public and an OLDER tag can be -# published later. The floor (what GitHub already has) is resolved from GitHub -# itself, so notes cover every change since the last publish even when several -# internal releases were skipped. -# -# The plan job exists as a NAMED job rather than a DRY_RUN flag so that someone -# publishing for the first time finds it without needing to know a variable name. -# --------------------------------------------------------------------------- -.publish-base: - stage: release - image: public.ecr.aws/docker/library/python:3.12-bookworm - variables: - # Full clone required on two counts: the publish path unshallows before - # reasoning about ancestry (a truncated graph answers "not an ancestor" for - # commits that are), and git's pack negotiation otherwise references objects - # past the shallow boundary, which GitHub rejects. - GIT_DEPTH: "0" - rules: - - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH && $CI_COMMIT_TAG == null - when: manual - allow_failure: false - - when: never - before_script: - - pip install --quiet --disable-pip-version-check 'git-cliff==2.10.1' - # $GITHUB_DEPLOY_KEY is base64-encoded (masked + protected CI variable). - # It lives on the publish jobs only — stage 1 never touches GitHub. - - apt-get update -qq && apt-get install -y -qq openssh-client - - eval $(ssh-agent -s) - - | - # 350 bytes, not 200: the smallest key type we would ever use is an - # OpenSSH ed25519 private key, which measures 387 bytes — so a 200-byte - # floor accepts a key truncated to roughly half its length. The threshold - # exists to catch a mis-pasted or clipped CI variable before ssh-add - # fails with something less obvious, so it has to sit just under the - # smallest legitimate key rather than far below it. - DECODED_LEN=$(printf '%s' "$GITHUB_DEPLOY_KEY" | base64 -di 2>/dev/null | wc -c || true) - if [ "${DECODED_LEN:-0}" -lt 350 ]; then - echo "ERROR: GITHUB_DEPLOY_KEY decodes to only ${DECODED_LEN:-0} bytes." >&2 - echo "hint: expected >= 350 (an ed25519 private key is ~387 bytes, RSA-2048" >&2 - echo " ~1800). A short value usually means the variable was truncated" >&2 - echo " when pasted, or holds a PUBLIC key rather than the private one." >&2 - exit 1 - fi - - printf '%s' "$GITHUB_DEPLOY_KEY" | base64 -di | ssh-add - - - mkdir -p ~/.ssh && ssh-keyscan github.com >> ~/.ssh/known_hosts - - git fetch origin --tags --force - -# Dry run. Set TAG to publish something other than the newest internal tag. -publish:github:plan: - extends: .publish-base - needs: [] - script: - - bash internal/release/publish-github.sh --dry-run ${TAG:-} - -# The irreversible act. Requires CONFIRM_TAG to equal the tag being published; -# run publish:github:plan first to learn the value. -publish:github: - extends: .publish-base - needs: [] - script: - - bash internal/release/publish-github.sh ${TAG:-} diff --git a/.gitlab/merge_request_templates/default.md b/.gitlab/merge_request_templates/default.md deleted file mode 100644 index cb483c5..0000000 --- a/.gitlab/merge_request_templates/default.md +++ /dev/null @@ -1,14 +0,0 @@ -## Summary - - - -## Release Notes - - - -## Checklist - -- [ ] Commits follow Conventional Commits (drives version derivation and release notes) -- [ ] CHANGELOG.md updated (if releasing) -- [ ] Tests pass -- [ ] No secrets in committed files diff --git a/docs/docs/developer-docs/internal/CLAUDE.md b/docs/docs/developer-docs/internal/CLAUDE.md deleted file mode 100644 index c8ddab2..0000000 --- a/docs/docs/developer-docs/internal/CLAUDE.md +++ /dev/null @@ -1,9 +0,0 @@ -# developer-docs/internal/ - -Internal-only docs. Committed to GitLab; filtered from GitHub release by `infra/scripts/github-push.sh` (path is in `EXCLUDE_PATHS`). - -- Use this section for material that should stay inside Amazon — internal release runbooks, ops notes, links to Brazil/Apollo/Pipelines/Taskei, etc. -- Sidebar autogenerates from disk. The section is visible whenever this directory exists; filtering it out at release time hides it from the GitHub docs site without code changes. -- Do **not** link to `internal/` pages from any public-facing page (anything outside `internal/`). Those links would 404 on the published GitHub site. -- Front matter conventions match the rest of `developer-docs/`: `title` required, `sidebar_position` for ordering, `title: Overview` on category index pages. -- To remove the entire section locally, delete the directory — Docusaurus regenerates the sidebar without it. diff --git a/docs/docs/developer-docs/internal/exploration/index.md b/docs/docs/developer-docs/internal/exploration/index.md deleted file mode 100644 index 6ea7811..0000000 --- a/docs/docs/developer-docs/internal/exploration/index.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -title: Overview -sidebar_label: Exploration -sidebar_position: 1 ---- - -# Exploration - -Exploratory and direction-setting documents for the IPA project. Content here -captures investigations, product-design proposals, and convergence studies that -inform future direction but are not yet committed plans or implementation specs. - -Like the rest of `internal/`, this section is committed to GitLab but filtered -out before publishing to GitHub. It is appropriate for material that should stay -inside Amazon while a direction is still being socialized. - -- **[LaunchBridge / IPA Convergence](./launchbridge-ipa-convergence.md)** — A - proposal to merge the Innovation Patterns Agent into the LaunchBridge product - under one brand, with a forward-looking plugin, installer, and bring-your-own - spec-driven-development approach. diff --git a/docs/docs/developer-docs/internal/exploration/launchbridge-ipa-convergence.md b/docs/docs/developer-docs/internal/exploration/launchbridge-ipa-convergence.md deleted file mode 100644 index ab79803..0000000 --- a/docs/docs/developer-docs/internal/exploration/launchbridge-ipa-convergence.md +++ /dev/null @@ -1,299 +0,0 @@ ---- -title: LaunchBridge / IPA Convergence -sidebar_position: 2 ---- - -# LaunchBridge / IPA Convergence - -## Executive Summary - -The Innovation Patterns Agent (IPA) and LaunchBridge are two suites of Claude -Code skills — collections of instruction documents that Claude Code activates on -demand to perform a defined task. The two suites have grown adjacent but -separate. This document proposes merging IPA into LaunchBridge under a single -brand, and lays out a forward-looking approach for doing so. - -The headline finding is that the two products are complementary, not -overlapping. LaunchBridge owns the application and evaluation layer — indexing, -governance, testing, prediction, metrics, and shipping — together with a mature, -multi-agent installer. IPA owns the infrastructure layer — composing and -deploying full-stack AWS environments — which LaunchBridge lacks entirely. The -merge direction follows directly from that asymmetry: LaunchBridge is the -chassis, and IPA is the missing engine. - -Three problems make the merge non-trivial, and this document treats each -honestly rather than glossing over them: branding and command namespacing, -startup context budget across a large skill catalog, and avoiding collisions -between similarly named skills. All three are solvable with the Claude Code -plugin mechanism and the installer LaunchBridge already ships. - -This is a direction-setting proposal. It makes the case for convergence, -describes the architecture at a high level, and surfaces the decisions that need -alignment. The implementation specification lives separately. - -The three points to carry forward: - -- **The suites are complementary.** Their capabilities barely overlap, so - convergence is mostly additive — it adds capability rather than forcing - features to be reconciled. -- **Package the result as a Claude Code plugin.** A plugin delivers command - namespacing, one-step installation, and the ability to pay context cost only - for the domains a customer enables. -- **Keep the supporting commitments lightweight.** Bring-your-own - spec-driven-development and the installer technology choice are best handled - with the smallest durable solution, not new machinery. - -## Why Converge - -IPA and LaunchBridge today differ in how their skills are invoked, and they -share no common distribution path. IPA skills are invoked by command name; many -LaunchBridge skills are triggered by keyword matches against their descriptions. -Keeping the two suites separate carries an ongoing cost: duplicated effort, the -risk of version drift on the assets they already share, and an incoherent story -for any customer who wants both infrastructure and evaluation capabilities in one -project. - -Each suite has a single defining gap that the other closes. IPA can compose and -deploy infrastructure, but it has no installer — its skills are placed by -manually copying files into a project. LaunchBridge has a mature, multi-agent -installer with incremental updates, but it deploys no infrastructure of its own. -A merge closes both gaps at once. Because the two capability sets barely -intersect, the merge is mostly additive: the friction lies in naming, context -budget, and brand, not in conflicting features that must be reconciled. - -### What Each Side Brings - -The combined capability map shows near-zero overlap across every domain. Where a -surface looks like a collision, it resolves on inspection into two skills -operating at different altitudes. - -| Domain | IPA | LaunchBridge | Relationship | -|---|---|---|---| -| Infrastructure compose and deploy | Yes — multiple stacks, generated Makefiles, CloudFormation and Terraform | None | IPA fills LaunchBridge's largest gap | -| Security | IAM role and permission provisioning | Static application security scanning | Complementary; different domains | -| CI/CD | Deploys AWS-native pipeline infrastructure | Scaffolds provider-agnostic CI configuration | Reconcilable; different altitude | -| Indexing and Q&A | None | Indexing and question-answering | LaunchBridge only | -| Test, evaluation, and metrics | None | Several evaluation and metrics skills | LaunchBridge only | -| Configuration and prompt registry | Deployment parameters | Application configuration registry | Different namespaces | -| Spec-driven development workflow | References it conceptually | Vendors and routes a shared workflow | Shared dependency | -| Multi-agent installer | None | Mature | LaunchBridge is the merge vehicle | - -Two apparent collisions deserve a one-line resolution each. IPA's security skill -provisions IAM roles and permission boundaries, while LaunchBridge's security -skill aggregates static-analysis scanners — these are different problems, and a -converged suite keeps both. IPA's pipeline skill deploys an AWS-native pipeline -as infrastructure, while LaunchBridge's shipping skill scaffolds -provider-agnostic continuous-integration configuration — the same problem space -at different altitudes, reconcilable rather than conflicting. - -There is exactly one true shared dependency: the family of skills that drives the -shared spec-driven-development and document workflow. Today it is referenced by -IPA and vendored by LaunchBridge. The merge should own it once, not twice. - -## The Convergence Architecture - -The merge rests on four forward-looking design decisions, each addressing one of -the hard problems named in the summary: how commands are namespaced, how context -cost is controlled at scale, how skills are distributed, and how the suite -accommodates whatever spec-driven-development framework a customer already uses. -Each is taken in turn below. - -### Namespacing via a Claude Code Plugin - -In Claude Code, a skill's invocation name comes from its directory name, and the -only way to produce a colon-separated command namespace is through a **plugin** — -a packaged bundle of skills (and optionally agents, hooks, and tool -configuration) that installs in one step. A plugin automatically prefixes every -command it contains with the plugin's name. Packaging the converged suite as a -plugin named `lb` therefore yields commands of the form `/lb:deploy` -automatically. Without a plugin, the achievable form is a flat command prefix -such as `/lb-deploy`, where the prefix is simply part of each skill's name. - -Plugins are the right vehicle for reasons beyond the namespace. They install in a -single step, they carry a version, and they can be split into **sub-plugins** — -smaller plugins grouped by domain — so that a customer pays the startup context -cost only for the groups they enable. Whether the suite ships as one plugin or as -several domain sub-plugins is an open decision; the recommendation is deferred to -"Decisions to Align On." - -### Context-Efficient, Just-in-Time Activation - -Claude Code loads skills through **progressive disclosure**: at the start of a -session, only each skill's name and short description are loaded; the full -instruction body loads only when the skill is triggered or invoked. The -session-start cost is therefore small per skill — on the order of a few hundred -tokens of metadata — but it accumulates. A catalog approaching forty skills -costs a few thousand tokens at startup, measured against a listing budget that -defaults to a small fraction of the model's context window. When that budget is -exceeded, descriptions are shortened, which degrades the keyword matching that -auto-triggers skills. - -The honest constraint is that Claude Code provides no native, path-conditional -skill gate. The intuitive goal of "show infrastructure skills only when the user -is working on infrastructure" has no first-class mechanism. The achievable -strategy is to group skills into domain sub-plugins, write precise descriptions -so triggering stays accurate, and — if needed — use session hooks to suggest -relevant skills. These numbers are guidance rather than hard limits; the right -move before committing to a single-plugin layout is to measure a real install -with Claude Code's diagnostics and confirm that no descriptions are being -truncated under budget pressure. - -### The Installer as the Merge Vehicle - -LaunchBridge's installer already solves the distribution problems IPA never -began. It maintains a registry of target agents and writes each agent's skills to -the correct location, so a single run can install for more than one agent. It -chooses between copying and symlinking depending on whether the install is for -active development or for delivery. It tracks a content hash per skill so that an -update re-copies only the skills that actually changed. It groups skills into -selectable bundles and supports non-interactive flags, which is precisely the -seam that makes "install some, not all" real today — at the granularity of a -single skill. It also exposes a provider hook for sourcing external skills, which -is the natural place for IPA's skills to plug in. Notably, the installer is the -most thoroughly tested asset across both repositories, which is itself an -argument for building the merge around it. - -There is one open technology question. The installer is implemented in -JavaScript today. A Python-based install path — invoked as a tool installed -directly from a git repository — is a proven model for this class of tooling and -is attractive for ecosystem consistency. But it is a reimplementation of the -installer, not a thin wrapper: it would have to reproduce the agent registry, the -copy-and-symlink logic, content-hash diffing, and submodule resolution. The -existing installer already supports installation from a git repository and -incremental updates, so this is a strategic preference to cost out deliberately, -not an urgent capability gap. - -### Bring Your Own SDD (BYOSDD) - -**Spec-driven development (SDD)** is the practice of capturing a feature's intent -in structured specification files before implementing it, and several frameworks -exist for doing so. The requirement here is simple: a customer can plug in -whichever SDD framework they already use, or adopt a suggested default — -**openspec**, a lightweight, portable, open-source SDD framework. - -The lightweight answer is that BYOSDD is a stance, not a piece of machinery, and -it rests on three inexpensive commitments. First, zero coupling: no converged -skill requires specification files to exist, so a customer using any framework — -or none — works without configuration. This property already holds today. -Second, a single optional setup question: during initialization, the suite asks -once whether to set up SDD, offering openspec as the recommended default, the -customer's own framework, or nothing — and acts only if openspec is chosen. -Third, a short note in the project's agent-context file telling the agent to -honor whatever specification artifacts are present by reading them as context. - -The proof that this is sufficient is the repository itself: it already runs two -SDD dialects side by side with no adapter between them. That coexistence is the -strongest available evidence that the requirement needs a stance and a default, -not a routing layer. - -## Decisions to Align On - -These are the choices that need stakeholder buy-in. Each is listed with the -recommended option and a one-line rationale; the supporting argument lives in the -architecture sections above. The intent is to let readers agree quickly or -redirect deliberately. - -| Decision | Recommended option | Rationale | -|---|---|---| -| Merge direction | Fold IPA into LaunchBridge | LaunchBridge has the installer, the multi-agent abstraction, and the test coverage; IPA has the missing infrastructure capability. | -| Plugin packaging | Multiple domain sub-plugins | Makes context cost opt-in by domain while still delivering the namespace. | -| BYOSDD shape | Stance plus optional openspec default | A routing manifest has no consumer today; zero coupling already works. | -| Just-in-time strategy | Sub-plugins plus precise descriptions | There is no native path gate; this is the achievable approximation. | -| Installer technology | Keep the current installer; defer the rewrite | It already does git-repo install and incremental updates; the rewrite is a preference. | -| Model coupling | Keep converged skills model-agnostic | Pinning a specific model undercuts the suite's multi-agent portability. | -| Shared-workflow ownership | Single source through the installer | Three owners of one asset invites version skew; consolidate to one. | -| First milestone | The plugin and namespace spike | Cheapest way to prove the namespace and measure context cost before committing. | - -## The Path Forward - -The path is phased so that the early, reversible work proves the foundations -before any commitment to brand or distribution model. The first two phases are -low-risk and can be undone; the later phases commit to the rebrand and the -distribution choice. Throughout, the Makefiles already delivered to customers -remain untouched — they carry no IPA-specific branding and depend on no IPA -tooling, so customer deployments are unaffected by the merge. - -| Phase | Goal | Reversible | -|---|---|---| -| 1. Plugin and namespace spike | Prove the `/lb:` namespace and measure startup context cost across a representative skill subset. | Yes | -| 2. BYOSDD stance and default | Guarantee zero SDD coupling, add the one optional setup question, and add the agent-context note. | Yes | -| 3. Installer integration | Register IPA's infrastructure skills as a selectable group in the LaunchBridge installer. | Yes | -| 4. Rebrand | Replace IPA branding with LaunchBridge across skills, context, and documentation. | Largely | -| 5. Alternative install path (optional) | Provide a Python-based install path if the cost spike justifies it. | N/A | - -The sequencing logic is deliberate. Phase 1 answers the single most consequential -architectural question — whether to ship one plugin or several — with measured -token numbers rather than estimates, and it does so before any brand work begins. -Phases 2 and 3 deliver customer-visible value (a clean SDD story and "install -some, not all") while remaining reversible. Only Phase 4 commits to the new -identity, and it is sequenced last among the required phases precisely so that the -foundations are proven first. Phase 5 is optional and gated on its own cost -assessment. - -## What We Are Not Building (Yet) - -A short, deliberate statement of what is out of scope keeps the proposal honest -and pre-empts over-engineering. Three items are explicitly deferred. - -- **A structured routing layer for SDD frameworks.** No converged skill needs to - read specification files uniformly across frameworks today, and the SDD tools - drive themselves. Such a layer would add a maintenance surface that must track - every framework's evolving commands, with no consumer to justify it. It should - be built only if a concrete skill later needs uniform cross-framework access to - specification artifacts. -- **The Python-based installer rewrite.** Deferred until a customer needs a - Python-only install path. The existing installer already supports git-repo - installation and incremental updates, so this is a preference rather than a - gap. -- **Custom path-conditional activation hooks.** Building machinery to show - infrastructure skills only inside infrastructure directories is speculative - effort with no native support. Domain sub-plugins and precise descriptions - cover the need without it. - -## Risks and Open Questions - -A proposal earns trust by naming risk plainly. The following are the substantive -risks the convergence carries, followed by the assumptions that remain -unvalidated. - -**Risks.** - -- **Namespace mechanics constrain the command form.** A true colon namespace - requires packaging as a plugin; without one, the suite is limited to a flat - command prefix. This decision shapes everything downstream and should be made - early. -- **Context budget at scale.** A combined catalog approaching forty skills can - trip the startup listing budget, shortening descriptions and degrading - auto-triggering. Splitting into domain sub-plugins is the mitigation; there is - no native path-conditional gate to fall back on. -- **Model-coupling divergence.** IPA pins a specific model; LaunchBridge stays - model-agnostic for portability. A merge must choose, and pinning a model leaks - Claude-specific assumptions into a suite that aims to be agent-agnostic. -- **Installer rewrite cost.** A Python-based install path is a real project, not - a wrapper, and its effort is currently unestimated. It needs its own cost spike - before any commitment. -- **Brand and identity churn.** Retiring the IPA brand means rewriting context - files, skill descriptions, and documentation. The work is real and must be - sequenced so that delivered customer Makefiles remain unaffected. -- **Shared-workflow ownership.** The shared SDD-and-document workflow currently - has three owners. The merge must pick a single distribution path or risk - version skew. - -**Unvalidated assumptions.** Two assumptions underlie the proposal and are -product strategy rather than verified findings: that customers want multi-agent -support, and that customers want bring-your-own-SDD. Both should be confirmed -with stakeholders rather than treated as settled. The token and budget figures -cited throughout are documented guidance, not hard limits, and should be measured -on a real install before they drive a final layout decision. - -## Recommendation - -Convergence is the right direction: the capabilities are complementary, the merge -is additive, and LaunchBridge already provides the distribution machinery IPA -needs. The recommended first step is the lowest-risk one — a plugin and namespace -spike that proves the `/lb:` command form and measures real context cost — so -that the most consequential decision, single plugin versus domain sub-plugins, -rests on evidence. The supporting commitments around SDD and installer technology -should stay deliberately lightweight until a concrete need justifies more. With -alignment on the decisions listed above, the phased path can begin without -disturbing any customer already running delivered infrastructure. diff --git a/docs/docs/developer-docs/internal/index.md b/docs/docs/developer-docs/internal/index.md deleted file mode 100644 index 0ce521a..0000000 --- a/docs/docs/developer-docs/internal/index.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -title: Overview -sidebar_label: Internal -sidebar_position: 99 ---- - -# Internal - -Internal-only documentation for the IPA project. Content here is committed to GitLab but **filtered out before publishing to GitHub** by `infra/scripts/github-push.sh`. - -This section is for material that is appropriate to share with internal Amazon contributors but should not appear on the public `aws-samples/sample-innovation-patterns` repo or its docs site — for example, internal release runbooks, environment-specific operational notes, or links to internal-only systems (Brazil, Apollo, Pipelines, Taskei). - -## Conventions - -- Each topic gets its own subdirectory or `.md` file. Front matter follows the standard developer-docs conventions (`title`, `sidebar_position`). -- Do **not** reference `internal/` content from public pages — those links would 404 on the GitHub site. -- The whole `internal/` directory is in `EXCLUDE_PATHS` in `infra/scripts/github-push.sh`, so adding files here automatically inherits the filter. - -## Visibility - -The Docusaurus sidebar is autogenerated, so this section appears in the left nav whenever the `internal/` directory exists on disk. Removing the directory (or filtering it out, as the GitHub release does) hides it without any config change. - -## Adding Content - -1. Create a new `.md` file or subdirectory under `internal/`. -2. Add `title` and (optionally) `sidebar_position` front matter. -3. If you create a subdirectory, add an `index.md` with `title: Overview` so the category page renders cleanly. -4. Verify the page renders locally (`npm start` from `docs/`) before committing. diff --git a/docs/docs/developer-docs/internal/operations/index.md b/docs/docs/developer-docs/internal/operations/index.md deleted file mode 100644 index 66b9cbb..0000000 --- a/docs/docs/developer-docs/internal/operations/index.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -title: Overview -sidebar_label: Operations -sidebar_position: 1 ---- - -# Operations - -Operational documentation for the IPA project, including release runbooks and environment management procedures. - -- **[Runbooks](runbooks)** — Step-by-step procedures for operational tasks diff --git a/docs/docs/developer-docs/internal/operations/runbooks/index.md b/docs/docs/developer-docs/internal/operations/runbooks/index.md deleted file mode 100644 index f2ecffd..0000000 --- a/docs/docs/developer-docs/internal/operations/runbooks/index.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -title: Overview -sidebar_label: Runbooks -sidebar_position: 1 ---- - -# Runbooks - -Operational runbooks for recurring IPA procedures. - -- **[Releasing](releasing.md)** — How to cut a release: VERSION bump, CHANGELOG generation, tag, and GitHub mirror diff --git a/docs/docs/developer-docs/internal/operations/runbooks/releasing.md b/docs/docs/developer-docs/internal/operations/runbooks/releasing.md deleted file mode 100644 index c443186..0000000 --- a/docs/docs/developer-docs/internal/operations/runbooks/releasing.md +++ /dev/null @@ -1,309 +0,0 @@ ---- -title: Releasing -sidebar_position: 10 ---- - -# Releasing - -How to cut a release of the IPA framework. The version is **derived** from Conventional Commit history — there is nothing to type and no file to bump. - -## TL;DR - -```bash -# 1. Land your work on main with conventional commit messages. -git push origin main - -# 2. Read the release:preview job output on that pipeline. It runs automatically -# and prints the version that would be cut plus the exact release notes. - -# 3. Click the "release" job in the same pipeline. -``` - -That is the whole flow. No `VERSION` file, no version argument, no changelog edit required before tagging — the notes are generated from commit messages and attached to the tag. - -## Prerequisites - -- Push access to `main` on GitLab -- **`git-cliff`** installed locally, only if you want to preview or regenerate `CHANGELOG.md` by hand — `brew install git-cliff`. CI installs its own pinned copy. - -## The Three Jobs - -The job names are the documentation. All three are jobs on a **branch** pipeline for `main`. - -| Job | Trigger | What it does | -|-----|---------|--------------| -| `release:preview` | **Automatic**, every push to `main` | Prints the derived version and the exact notes. Creates nothing. Non-blocking. | -| `release` | Manual | Derives minor/patch, tags `origin`, creates a GitLab Release. | -| `release:major` | Manual, gated | Forces a major bump. Requires `CONFIRM_VERSION`. | - -### release:preview - -Runs on its own, so the answer to "what would a release look like right now?" is always already on screen. When nothing in the window affects the version it prints *nothing to release* and passes — a preview that failed on most pushes would be a preview everybody learned to ignore. - -### release - -Derives the version with `git-cliff --bumped-version`, asserts tag hygiene and that the derived version agrees with history, tags, pushes to `origin`, and creates a GitLab Release whose body is the generated notes. - -**It publishes nothing to GitHub.** That is deliberate — see [Publishing to GitHub](#publishing-to-github) below. - -### release:major - -While the project is pre-1.0, `cliff.toml` sets `breaking_always_bump_major = false`, so a `feat!:` commit derives `0.x+1` rather than `1.0.0`. Reaching a major version therefore requires this job, and this job requires confirmation: - -``` -error: major release requires explicit confirmation -would cut: v1.0.0 -hint: re-run this job with CONFIRM_VERSION=v1.0.0 -``` - -Run it once to learn the value, then re-run it with that value supplied as a CI variable. It fails before creating anything. - -:::note Post-1.0 behavior -`breaking_always_bump_major = false` is **inert** once the major reaches 1 — at `v1.0.0` a `feat!:` commit derives `v2.0.0` normally. After the 1.0 transition the ordinary `release` job will coin majors, and `release:major` becomes decorative. That is the correct semantic: past 1.0, a breaking change genuinely should mean a major, and `release:preview` is what keeps it from being a surprise. -::: - -## Publishing to GitHub - -Cutting an internal release and publishing to the public mirror are **separate acts**. The `release` job pushes nothing public. - -| Job | Trigger | What it does | -|-----|---------|--------------| -| `publish:github:plan` | Manual | Prints the tag, the floor, the full notes body, the filtered-path report, and the `CONFIRM_TAG` value. Publishes nothing. | -| `publish:github` | Manual, gated | Publishes. Requires `CONFIRM_TAG`. The one irreversible act in the release path. | - -### Procedure - -1. Run `publish:github:plan`. Set the `TAG` variable to publish something other than the newest internal tag; leave it unset for the newest. -2. Read its output — the notes body is exactly what the public Release will carry. -3. Run `publish:github` with `CONFIRM_TAG` set to the value the plan printed. - -### Publishing an older tag - -Supply `TAG=v0.3.0` to either job. Nothing is retagged internally: the internal tag already exists and is correct, and only the public side is brought forward. The operator's local tags are byte-identical before and after a publish run. - -### What a gap in the public tag sequence means - -The public tag sequence is allowed to be gapped — `v0.1.7` then `v0.4.0` with nothing between — and it carries a specific meaning worth being precise about. - -**Publishing does not withhold code.** The publish path amends the tip and force-pushes `main`, so the code of a release you never published reaches the public repository the next time you publish anything. What is withheld is the **tag and the Release object**. - -So a gap describes real, already-public code that no Release object documents. That is why the notes for a publish cover everything since the last publish rather than just the newest version: the release notes for `v0.4.0` published over a floor of `v0.1.7` contain a section per intervening version, so nothing that shipped goes undescribed. - -The floor is resolved from GitHub itself — GitHub is the only authority on what GitHub already has, so there is no "last published" record to drift. - -For the mechanisms and their hazards, see [`internal/release/README.md`](https://code.aws.dev/proserve/genaiid/other/candidate-reusable-assets/innovation-patterns/-/blob/main/internal/release/README.md). - -## Background: Trunk-Based Workflow - -The project uses **trunk-based development** on `main`. Daily work lands directly on `main` (or via short-lived feature branches merged back to `main`). There is no `develop` branch. - -Prior to v0.1.7, the project used a Gitflow-lite model: `develop` was the default branch, and releases required merging `develop` into `main`, then reconciling SHAs back. The trunk-based model eliminates the merge ceremony and SHA reconciliation overhead. - -## Background: Version Derivation - -There is **no `VERSION` file**. It was retired because it conflated two different questions — "what is the next version?" (a release-time derivation) and "what is the current version?" (a build-time display) — and because being hand-maintained, it was routinely wrong: it read `0.1.8` while the newest tag was `v0.1.7` and no `v0.1.8` ever existed. - -Tags are the single source of truth. `git-cliff --bumped-version` answers the first question; `git describe` answers the second. - -Two configured behaviors matter when reading a derivation: - -- **`Update:`-prefixed commits are skipped entirely.** They affect neither the version nor any changelog section. A batch of these predates the convention. -- **A breaking change stays within `0.x` while pre-1.0.** See `release:major` above. - -### git-cliff - -[git-cliff](https://git-cliff.org/) generates the CHANGELOG from git history by parsing Conventional Commit messages, and derives the next version from the same history. Configuration is `cliff.toml` at the repo root. - -CI installs a pinned `git-cliff==2.10.1` — the version every mechanism in the release path was verified against. - -### Conventional Commits - -See the public [Commit Messages](../../../../developer-docs/contributing/commit-messages.md) reference for the full type/scope table. Quick reference: - -| Type | CHANGELOG Section | -|------|-------------------| -| `feat` | Added | -| `fix` | Fixed | -| `docs` | Documentation | -| `perf` | Performance | -| `refactor` | Changed | -| `ci`, `build` | CI/Build | -| `revert` | Reverted | -| `style`, `test`, `chore`, `Update:` | (skipped) | - -### infra/scripts/release.mk - -| Target | What it does | -|--------|--------------| -| `release-preview` | Prints the derived version and notes. Creates nothing. The local counterpart of `release:preview`. | -| `release-changelog` | Regenerates `CHANGELOG.md` through the derived version | -| `release-prep` | Confirms the derived version, then runs `release-changelog` | -| `release-check` | Asserts tag hygiene and that a tag matches the derived version | - -All of them derive `VERSION` by default. Override explicitly when you need to: `VERSION=0.3.0`. - -### infra/scripts/release-check.sh - -Two assertions: - -1. **Tag hygiene** — every local release tag must point at the same commit as `origin`'s tag of the same name. This is a reachable state, not a hypothetical: the mirror push amends the release commit and force-moves the tag locally, so any clone that has run a release carries the disagreement. A disagreeing tag poisons both version derivation and any publish range. -2. **Derived matches requested** — the tag about to be created must equal what history derives. - -It runs **before** the tag is created. Both assertions exist to fail without leaving a wrong tag behind. - -## Procedure: Cutting a Release - -### Step 1: Land your work - -Commit with Conventional Commit messages and push to `main`. The message format is what determines the version and the release notes — a non-conventional commit derives no bump and appears in no section. - -### Step 2: Read release:preview - -Open the pipeline for your commit. The `release:preview` job has already run. It prints: - -``` -current release: v0.1.7 -would release: v0.2.0 - ---- release notes for v0.2.0 --- -... ---- end release notes --- -``` - -If it says *nothing to release*, no commit in the window affects the version. - -### Step 3: Click release - -Find the `release` job in the `release` stage of the same pipeline and click the play button. For a major release, click `release:major` instead and supply `CONFIRM_VERSION`. - -### Step 4: Verify - -- [ ] The tag exists on GitLab -- [ ] A GitLab **Release object** exists for that tag, not just a bare tag -- [ ] The Release body carries the generated notes with headings intact -- [ ] Nothing changed on GitHub — publishing is separate - -### Optional: Update the committed CHANGELOG - -The published release notes come from the tag annotation, so no changelog commit is required to release. To refresh the committed `CHANGELOG.md`: - -```bash -make -f infra/scripts/release.mk release-prep -git commit -am 'docs: update changelog' -``` - -## Procedure: Hotfix on Main - -1. Create a short-lived feature branch from `main`: - ```bash - git checkout -b fix/critical-bug main - ``` -2. Make the fix, commit with a `fix:` prefix -3. Open an MR targeting `main`, get review, merge -4. Follow the standard release procedure above — the `fix:` commit derives a patch bump - -## Troubleshooting - -### release-check fails: local tag disagrees with origin - -Your clone has a tag pointing at a different commit than `origin`'s tag of the same name. Almost always caused by having run a mirror push, which force-moves tags locally by design. - -**Fix:** the error prints it — `git fetch origin --tags --force`. - -### release-check fails: tag does not match the derived version - -You asked for a version that history does not derive. The error names both values. - -**Fix:** release the derived version, or add the commits that would justify the one you wanted. - -### "Nothing to release" - -No commit since the last tag affects the version. Most often every commit in the window is `chore:`, `test:`, `style:`, or `Update:`-shaped — all of which are configured to derive no bump. - -**Fix:** if the work deserves a release, it deserves a conventional commit message. Nothing is wrong with the pipeline. - -### release:major fails without creating anything - -Expected — that is the gate. Read the `hint:` line, which names the exact `CONFIRM_VERSION` value, and re-run with it. - -### The GitLab Release object is missing but the tag exists - -The tag push succeeded and the Release API call failed. The tag is fine. - -**Fix:** re-run the job, or call `infra/scripts/gitlab-release.sh ` directly. It is idempotent — an existing Release is reported as success rather than an error. - -### git-cliff not installed locally - -Only the local `make` targets need it; CI installs its own. - -```bash -brew install git-cliff -``` - -### Pipeline missing the manual buttons - -The release jobs only appear on pipelines running on the default branch (`main`). Pipelines on feature branches do not show them. - -## Reference: CI Variables - -### CONFIRM_VERSION - -Supplied at job-run time to `release:major` only. Not a stored variable — run the job once, read the value from the failure message, re-run with it. - -### GITHUB_DEPLOY_KEY - -An SSH private key with push access to `github.com:aws-samples/sample-innovation-patterns`. Used only by the **publishing** jobs (stage 2), not by any job in this runbook. - -**Setup steps:** - -1. Generate an SSH keypair: - ```bash - ssh-keygen -t ed25519 -C "gitlab-ci-deploy" -f deploy_key -N "" - ``` - -2. Add the **public key** to GitHub: - - Go to `github.com/aws-samples/sample-innovation-patterns` → Settings → Deploy Keys - - Add `deploy_key.pub` with **write access** enabled - -3. Base64-encode the private key (GitLab rejects masking for values containing whitespace/newlines): - ```bash - base64 -i deploy_key | tr -d '\n' - ``` - -4. Add it to GitLab under Settings → CI/CD → Variables: - - Key: `GITHUB_DEPLOY_KEY` - - Value: the base64 string from step 3 - - Flags: **Masked**, **Protected** - -5. Delete the local keypair: - ```bash - rm deploy_key deploy_key.pub - ``` - -The CI job decodes it at runtime: `printf '%s' "$GITHUB_DEPLOY_KEY" | base64 -di | ssh-add -`. - -## Reference: Release Flow Diagram - -```mermaid -sequenceDiagram - participant Dev as Builder (main) - participant CI as GitLab CI - - Dev->>Dev: Work on main with conventional commits - Dev->>CI: Push to main - CI->>CI: ASH scan + Pages deploy (automatic) - CI->>CI: release:preview — derive version, print notes (automatic) - Dev->>Dev: Read the preview - Dev->>CI: Click "release" (manual) - CI->>CI: release-check.sh — tag hygiene + derived==requested - CI->>CI: Derive notes, create annotated tag - CI->>CI: Push tag to origin - CI->>CI: Create GitLab Release object - Note over CI: Nothing published to GitHub — that is stage 2 -``` - -## Migration History - -v0.1.6 was the last release under the develop → main merge flow. Starting with v0.1.7, the project uses trunk-based development on `main`. - -Before this revision, one manual `tag-and-release` job read the `VERSION` file, tagged, and mirrored to GitHub in consecutive lines of a single script — so an internal release could not be cut without also publishing publicly, and an older tag could not be published at all. That job is now three named jobs, the `VERSION` file is gone, and public publishing is a separate stage. diff --git a/internal/README.md b/internal/README.md deleted file mode 100644 index e69de29..0000000 diff --git a/internal/integration-tests/.python-version b/internal/integration-tests/.python-version deleted file mode 100644 index e4fba21..0000000 --- a/internal/integration-tests/.python-version +++ /dev/null @@ -1 +0,0 @@ -3.12 diff --git a/internal/integration-tests/README.md b/internal/integration-tests/README.md deleted file mode 100644 index e69de29..0000000 diff --git a/internal/integration-tests/main.py b/internal/integration-tests/main.py deleted file mode 100644 index 22c1675..0000000 --- a/internal/integration-tests/main.py +++ /dev/null @@ -1,6 +0,0 @@ -def main(): - print("Hello from integration-tests!") - - -if __name__ == "__main__": - main() diff --git a/internal/integration-tests/pyproject.toml b/internal/integration-tests/pyproject.toml deleted file mode 100644 index 01ed531..0000000 --- a/internal/integration-tests/pyproject.toml +++ /dev/null @@ -1,7 +0,0 @@ -[project] -name = "integration-tests" -version = "0.1.0" -description = "Add your description here" -readme = "README.md" -requires-python = ">=3.12" -dependencies = [] diff --git a/internal/release/README.md b/internal/release/README.md deleted file mode 100644 index 7daeb9b..0000000 --- a/internal/release/README.md +++ /dev/null @@ -1,99 +0,0 @@ -# Internal Release - -Assets supporting IPA's release process. The process has **two independent stages**, and understanding the split is most of understanding the release path. - -| Stage | Where | Jobs | Effect | -|-------|-------|------|--------| -| 1 — internal release | GitLab | `release:preview`, `release`, `release:major` | Derives a version, tags `origin`, creates a GitLab Release. **Publishes nothing publicly.** | -| 2 — public publish | GitLab → GitHub | `publish:github:plan`, `publish:github` | Publishes a **chosen** tag to the public mirror with cumulative notes. | - -Stage 1 is documented in the [releasing runbook](../../docs/docs/developer-docs/internal/operations/runbooks/releasing.md). This file documents stage 2. - -## What publishing actually withholds - -**Publishing does not withhold code.** The publish path amends the tip and force-pushes `main`, so the code of an internal release you never published still reaches the public repository the next time you publish anything. - -What publication withholds is **tags and Release objects**. That is what makes cumulative release notes a correctness requirement rather than a courtesy: a gap in the public tag sequence describes real, already-public code that no Release object documents. So the notes for a publish must cover everything since the last publish, not just the newest version. - -## The floor - -The **floor** is the newest version already published to GitHub. It is resolved by listing tags on the GitHub remote, filtering to strict semver, and taking the highest. - -```bash -git ls-remote --tags github | awk '{print $2}' | sed 's|^refs/tags/||' \ - | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1 -``` - -Three details are load-bearing: - -- **GitHub is the only authority on what GitHub has.** A marker file tracking "last published" would be a second source of truth, and drift means either republishing notes or silently dropping a version's worth. -- **`sort -V`, not `sort`.** Plain `sort` ranks `v0.1.2` above `v0.1.10` — verified, and it will matter the first time a patch number reaches double digits. -- **The floor supplies a NAME, not a commit.** GitHub's tag objects point at *amended* commits (the filtered tree has different SHAs by construction), so the notes range must resolve both endpoints against `origin`'s tags of those names. - -The notes window is `FLOOR..TAG`, **exclusive of the floor**, which is exactly "everything since the last publish." - -## Usage - -```bash -# Preview: prints TAG, FLOOR, the full notes body, the filtered-path report, -# and the CONFIRM_TAG value to supply. Publishes nothing. -internal/release/publish-github.sh --dry-run - -# Publish a specific tag — including an older one. -TAG=v0.4.0 internal/release/publish-github.sh --dry-run -CONFIRM_TAG=v0.4.0 TAG=v0.4.0 internal/release/publish-github.sh -``` - -In CI, run `publish:github:plan` first and read the `CONFIRM_TAG` value out of its output. Publishing an older tag retags nothing internally — the internal tag already exists and is correct; only the public side is being brought forward. - -## Hazards - -Each of these fails **silently** if mishandled, which is why the script asserts rather than trusts. - -### The tag annotation loses its headings - -`git tag -a -m "$NOTES"` destroys every `##` and `###` heading. Git's default `--cleanup=strip` treats `#`-leading lines as comments, it **exits 0**, and it warns nobody — so a release published that way carries a flat, ungrouped, unversioned bullet list and nothing signals the loss. - -The publish path builds the tag object with `git mktag`, which stores the body byte-for-byte, and then asserts the headings survived. `.github/workflows/release.yml` asserts again when reading it back. - -### Force-moving tags in the operator's clone - -`git tag -f` is what corrupted local `v0.1.7`: the old `github-push.sh` retagged in the invoking clone after amending, leaving the local tag pointing at a mirror-amended commit while `origin` held a different commit for the same name. That disagreement poisons both version derivation (`git describe` returns the previous tag) and any publish range. - -**A worktree does not fix this.** Worktrees isolate the index, HEAD, and checkout — they share `refs/tags` with the main repository, so `git tag -f` inside one still moves the operator's tag. The publish path therefore writes **no local ref at all**: it builds the annotated tag object with `git mktag` and pushes it by SHA. - -`release-check.sh` asserts local tags agree with `origin` on every release, so a recurrence is caught rather than discovered. - -### `--force-with-lease` is not a divergence guard - -The lease compares against `refs/remotes/github/main`, and a fresh clone establishes that ref at whatever the remote currently holds. So the lease passes precisely when a fresh clone is used — which is always, in CI. It protects against a stale local view, not against clobbering public work. - -The real guard is an explicit content assertion. It is kept alongside the lease, not instead of it. - -### The ancestry check must unshallow first - -`.git/shallow` can contain the mirror tip, in which case `git merge-base --is-ancestor` answers from a one-commit graph and **fails closed** — reporting "not an ancestor" for commits that genuinely are ancestors. An assertion that always fails is worse than none, because operators learn to bypass it. - -The publish path unshallows before reasoning about history. - -### Behind is not divergent - -The mirror's commits are amended versions of internal commits: different SHAs, identical content, by design. So a commit-identity comparison reports every mirror commit as divergent even when the mirror is perfectly reconciled. - -The assertion compares **content**, and excludes deliberate removals (the internal-only path filter, plus files deleted by an internal commit). Being behind is a mirror's normal state and proceeds. Only content the mirror holds and the internal line lacks stops the publish — and the failure names it, because the mirror accepts pull requests and a force-push can destroy work that did not originate internally. - -## Files - -| File | Role | -|------|------| -| `publish-github.sh` | The single implementation: floor resolution, cumulative notes, verbatim tag object, filtered tree, divergence assertion, `--dry-run` | -| `../../infra/scripts/github-push.sh` | Deprecated wrapper delegating here, so exactly one implementation exists | -| `../../infra/scripts/release-check.sh` | Tag hygiene + derived-version assertions (stage 1) | -| `../../infra/scripts/gitlab-release.sh` | Creates the GitLab Release object for a tag (stage 1) | -| `../../.github/workflows/release.yml` | Reads the tag annotation and creates the GitHub Release | - -## Known limitation: tag pushes do not trigger the customer pipeline - -The generated customer CodePipeline triggers on branch references only — `referenceType: [branch]` in both `infra/cfn/codepipeline/codepipeline.yml` and `infra/tf/codepipeline/main.tf`. Tag pushes are inert. - -This is documented rather than worked around: changing it would touch a deployed CloudFormation template, its Terraform twin, the stack skill, and CloudFormation/Terraform parity, to enable a trigger nothing currently asks for. diff --git a/internal/release/publish-github.sh b/internal/release/publish-github.sh deleted file mode 100755 index 09c4d58..0000000 --- a/internal/release/publish-github.sh +++ /dev/null @@ -1,425 +0,0 @@ -#!/usr/bin/env bash -# Publish a chosen internal tag to the public GitHub mirror, with release notes -# covering every change since the last GitHub publish. -# -# Usage: -# internal/release/publish-github.sh [--dry-run] [TAG] -# TAG=v0.4.0 internal/release/publish-github.sh --dry-run -# -# Stage 2 of a two-stage release. Stage 1 (the `release` GitLab job) cuts an -# internal tag and publishes nothing publicly; this publishes a chosen tag — -# including an older one — to GitHub. -# -# Publishing withholds only TAGS and RELEASE OBJECTS, never code: the mirror -# amends the tip and force-pushes main, so a skipped release's code lands -# publicly anyway. That is what makes cumulative notes correctness rather than -# courtesy — a gap in the public tag sequence describes real published code that -# no Release object currently documents. -# -# Four values are computed, each with a subtlety that breaks it if missed: -# -# TAG newest strict-semver tag on origin, or an explicit override. -# Must filter to strict semver or a stray v0.9.9-rc1 wins. -# FLOOR newest strict-semver tag on github. Needs sort -V; plain sort ranks -# v0.1.2 above v0.1.10 (verified). -# NOTES git-cliff FLOOR..TAG --strip all. GitHub supplies the floor's NAME; -# the range must resolve against origin's commit for that name, because -# GitHub's tag objects point at amended commits. -# tree EXCLUDE_PATHS applied in a throwaway worktree. The old -# github-push.sh ran `git tag -f` in the operator's clone, which is -# what corrupted local v0.1.7. - -set -euo pipefail - -DRY_RUN=false -ARGS=() -for arg in "$@"; do - case "$arg" in - --dry-run) DRY_RUN=true ;; - -*) echo "error: unknown flag: $arg" >&2; exit 1 ;; - *) ARGS+=("$arg") ;; - esac -done - -GITHUB_REMOTE="github" -GITHUB_REPO="git@github.com:aws-samples/sample-innovation-patterns.git" -ORIGIN_REMOTE="origin" -SEMVER_RE='^v[0-9]+\.[0-9]+\.[0-9]+$' - -REPO_ROOT="$(git rev-parse --show-toplevel)" -cd "$REPO_ROOT" - -# --------------------------------------------------------------------------- -# Internal-only paths -# --------------------------------------------------------------------------- - -# Paths that stay in GitLab but must never ship to GitHub. -EXCLUDE_PATHS=( - ".gitlab-ci.yml" - ".gitlab" - ".specify" - "docs/docs/developer-docs/internal" - "docs/docs/guides/releasing.md" - "scripts/.gitignore" - "internal" -) - -# Asserted below. A quoting or separator defect that merges two entries into one -# silently reduces the number of paths filtered, so the count is checked rather -# than assumed. Update this when adding an entry above. -EXPECTED_EXCLUDE_COUNT=7 - -# Entries deliberately absent from the tracked tree. Everything NOT listed here -# must have been tracked in HEAD before the removal loop — otherwise a typo is -# indistinguishable from a successful removal, since both leave the path absent. -# -# .specify on disk but git-ignored. Excluded as -# belt-and-braces in case it is ever un-ignored. -# docs/docs/guides/releasing.md has NEVER existed on disk. Retained to reserve -# the name: a guide written at this exact path -# would be silently stripped from the public -# tree. Write builder release docs elsewhere. -INTENTIONALLY_ABSENT=( - ".specify" - "docs/docs/guides/releasing.md" -) - -is_intentionally_absent() { - local needle="$1" entry - for entry in "${INTENTIONALLY_ABSENT[@]}"; do - [ "$entry" = "$needle" ] && return 0 - done - return 1 -} - -if [ "${#EXCLUDE_PATHS[@]}" -ne "$EXPECTED_EXCLUDE_COUNT" ]; then - echo "error: EXCLUDE_PATHS holds ${#EXCLUDE_PATHS[@]} elements, expected $EXPECTED_EXCLUDE_COUNT" >&2 - echo "hint: a missing separator merges two entries into one, silently reducing" >&2 - echo " what is filtered. Check quoting, or update EXPECTED_EXCLUDE_COUNT" >&2 - echo " if an entry was added deliberately." >&2 - exit 1 -fi - -# --------------------------------------------------------------------------- -# Tag and floor resolution -# --------------------------------------------------------------------------- - -remote_semver_tags() { - git ls-remote --tags "$1" 2>/dev/null \ - | awk '{print $2}' \ - | sed 's|^refs/tags/||' \ - | grep -E "$SEMVER_RE" \ - | sort -V -} - -git remote add "$GITHUB_REMOTE" "$GITHUB_REPO" 2>/dev/null || true - -TAG="${ARGS[0]:-${TAG:-}}" -if [ -z "$TAG" ]; then - TAG="$(remote_semver_tags "$ORIGIN_REMOTE" | tail -1)" - if [ -z "$TAG" ]; then - echo "error: no strict-semver tag found on $ORIGIN_REMOTE" >&2 - exit 1 - fi - echo "TAG not supplied — defaulting to the newest tag on $ORIGIN_REMOTE" -fi - -if ! remote_semver_tags "$ORIGIN_REMOTE" | grep -qx "$TAG"; then - echo "error: $TAG is not a strict-semver tag on $ORIGIN_REMOTE" >&2 - echo "hint: available tags:" >&2 - remote_semver_tags "$ORIGIN_REMOTE" | sed 's/^/ /' >&2 - exit 1 -fi - -# GitHub is the only authority on what GitHub already has. A marker file would be -# a second source of truth that can drift, and drift here means either -# republishing notes or silently dropping a version's worth of them. -FLOOR="$(remote_semver_tags "$GITHUB_REMOTE" | tail -1 || true)" - -echo "TAG: $TAG" -if [ -n "$FLOOR" ]; then - echo "FLOOR: $FLOOR (newest tag already on $GITHUB_REMOTE)" -else - echo "FLOOR: none — no semver tag on $GITHUB_REMOTE, notes will span full history" -fi - -# The window must run forward. Publishing a tag at or below the floor would -# produce a backwards range, which git-cliff answers with an "Unreleased" -# section rather than an error — a plausible-looking body describing nothing. -# -# Note this is not the same as "publishing an older tag is unsupported": an -# older tag publishes fine as long as it is newer than what GitHub already has. -# What cannot be done is publishing BEHIND the public mirror, because the notes -# window for that is empty by definition and the mirror is already ahead. -if [ -n "$FLOOR" ] && [ "$TAG" = "$FLOOR" ]; then - echo "error: $TAG is already the newest tag on $GITHUB_REMOTE — nothing to publish" >&2 - exit 1 -fi - -if [ -n "$FLOOR" ] && [ "$(printf '%s\n%s\n' "$TAG" "$FLOOR" | sort -V | tail -1)" = "$FLOOR" ]; then - echo "error: $TAG is older than the newest tag already on $GITHUB_REMOTE ($FLOOR)" >&2 - echo "hint: the public mirror is already ahead of $TAG, so there is nothing since" >&2 - echo " the last publish to describe. Publish a tag newer than $FLOOR." >&2 - exit 1 -fi - -# Make sure the local objects for both endpoints exist and agree with origin. -git fetch --quiet "$ORIGIN_REMOTE" --tags --force - -# --------------------------------------------------------------------------- -# Cumulative notes -# --------------------------------------------------------------------------- - -NOTES_FILE="$(mktemp)" -trap 'rm -f "$NOTES_FILE"' EXIT - -# FLOOR supplies the NAME from GitHub; the range resolves against origin's commit -# for that name. GitHub's tag objects point at amended commits, so resolving the -# range there would span the wrong window or fail outright. -if [ -n "$FLOOR" ]; then - if ! git rev-parse --verify --quiet "refs/tags/$FLOOR" >/dev/null; then - echo "error: floor tag $FLOOR exists on $GITHUB_REMOTE but not locally" >&2 - echo "hint: git fetch $ORIGIN_REMOTE --tags --force" >&2 - exit 1 - fi - git-cliff "refs/tags/$FLOOR".."refs/tags/$TAG" --strip all > "$NOTES_FILE" -else - git-cliff --tag "$TAG" --strip all > "$NOTES_FILE" -fi - -if [ ! -s "$NOTES_FILE" ]; then - echo "error: generated notes are empty for $FLOOR..$TAG" >&2 - echo "hint: is $TAG newer than $FLOOR? Nothing to publish otherwise." >&2 - exit 1 -fi - -echo -echo "--- release notes ($FLOOR..$TAG, exclusive of the floor) ---" -cat "$NOTES_FILE" -echo "--- end release notes ---" -echo - -# --------------------------------------------------------------------------- -# Divergence assertion -# --------------------------------------------------------------------------- - -# Two things are load-bearing here. -# -# UNSHALLOW FIRST. .git/shallow may contain the mirror tip, in which case -# ancestry questions are answered from a truncated graph and FAIL CLOSED — -# reporting "not an ancestor" for commits that genuinely are ancestors. An -# assertion that always fails is worse than none: operators learn to bypass it. -# -# ASSERT ON CONTENT, NOT COMMIT IDENTITY. The mirror's commits are AMENDED -# versions of internal commits — different SHAs for identical content, by design. -# So "is this public commit an ancestor?" answers no for every mirror commit even -# when the mirror is perfectly reconciled. The real question is whether the -# mirror holds content the internal line lacks. -# -# --force-with-lease is NOT the guard: a fresh CI clone establishes its -# remote-tracking ref at whatever the remote holds, so the lease passes precisely -# when a fresh clone is used, which is always in CI. -assert_no_divergence() { - if [ "$(git rev-parse --is-shallow-repository)" = "true" ]; then - echo "unshallowing before reasoning about ancestry..." - git fetch --quiet --unshallow "$ORIGIN_REMOTE" 2>/dev/null || git fetch --quiet --unshallow || true - fi - - if ! git fetch --quiet "$GITHUB_REMOTE" main 2>/dev/null; then - echo "note: $GITHUB_REMOTE/main not fetchable (likely first publish) — skipping divergence check" - return 0 - fi - - # Paths the mirror has that the internal line does not. - # - # Deliberate removals must be excluded or this fires on every run: EXCLUDE_PATHS - # is stripped on every publish, and files removed by an internal commit are - # absent internally by design. Both are "the mirror is behind", not divergence. - # An assertion that always fails teaches operators to bypass it, so the - # filtering here is the difference between a real guard and a nuisance. - local candidates orphans="" - candidates="$(comm -23 \ - <(git ls-tree -r FETCH_HEAD --name-only | sort) \ - <(git ls-tree -r HEAD --name-only | sort) || true)" - - local path excluded prefix - while IFS= read -r path; do - [ -z "$path" ] && continue - excluded=false - for prefix in "${EXCLUDE_PATHS[@]}"; do - # Match the entry itself or anything beneath it. - if [ "$path" = "$prefix" ] || [ "${path#"$prefix"/}" != "$path" ]; then - excluded=true - break - fi - done - # Deliberately removed on the internal line: present in the mirror's tree but - # deleted by an internal commit rather than never having existed. - if [ "$excluded" = false ] && git log --oneline -1 --diff-filter=D -- "$path" | grep -q .; then - excluded=true - fi - [ "$excluded" = false ] && orphans="${orphans}${path}"$'\n' - done <<< "$candidates" - - orphans="$(printf '%s' "$orphans" | sed '/^$/d')" - - if [ -n "$orphans" ]; then - echo "error: $GITHUB_REMOTE/main holds content absent from the internal line" >&2 - echo "$orphans" | sed 's/^/ /' >&2 - echo "hint: the public mirror accepts pull requests, so a force-push can destroy" >&2 - echo " work that did not originate internally. Reconcile these paths onto" >&2 - echo " the internal line first, or confirm they were deliberately removed." >&2 - return 1 - fi - - echo "ok: $GITHUB_REMOTE/main is behind, not divergent — safe to publish" -} - -assert_no_divergence - -# --------------------------------------------------------------------------- -# Filtered tree + notes-carrying retag, in a THROWAWAY WORKTREE -# --------------------------------------------------------------------------- - -# The operator's clone is never mutated. github-push.sh's `git tag -f` ran in the -# invoking clone and is what corrupted local v0.1.7; a worktree gives the amend -# and retag their own index, HEAD, and checkout, so local tags are untouched by -# construction rather than by care. -WORKTREE_DIR="$(mktemp -d)/publish" -cleanup() { - rm -f "$NOTES_FILE" - if [ -n "${WORKTREE_DIR:-}" ] && [ -d "$WORKTREE_DIR" ]; then - git worktree remove --force "$WORKTREE_DIR" 2>/dev/null || true - fi - git worktree prune 2>/dev/null || true -} -trap cleanup EXIT - -git worktree add --quiet --detach "$WORKTREE_DIR" "refs/tags/$TAG" -cd "$WORKTREE_DIR" - -git config user.email "ci@code.aws.dev" -git config user.name "GitLab CI" - -# Snapshot tracking state BEFORE the removal loop. Afterwards every entry is -# absent, so "removed" and "never present" are indistinguishable. -TRACKED_BEFORE=() -for path in "${EXCLUDE_PATHS[@]}"; do - if git ls-files --error-unmatch "$path" &>/dev/null; then - TRACKED_BEFORE+=("$path") - fi -done - -was_tracked_before() { - local needle="$1" entry - for entry in "${TRACKED_BEFORE[@]+"${TRACKED_BEFORE[@]}"}"; do - [ "$entry" = "$needle" ] && return 0 - done - return 1 -} - -for path in "${EXCLUDE_PATHS[@]}"; do - if git ls-files --error-unmatch "$path" &>/dev/null; then - git rm -rq "$path" - fi -done - -# Defensively strip any generated artifacts that may have slipped into git. -# These match scripts/.gitignore: top-level scripts/*.mk except test.mk, and -# top-level scripts/*.md except INSTALL-RUNBOOK.md. infra/scripts/ is untouched. -if [ -d scripts ]; then - while IFS= read -r -d '' path; do - git ls-files --error-unmatch "$path" &>/dev/null && git rm -qf "$path" - done < <(find scripts -maxdepth 1 -type f \( -name '*.mk' ! -name 'test.mk' \) -print0) - - while IFS= read -r -d '' path; do - git ls-files --error-unmatch "$path" &>/dev/null && git rm -qf "$path" - done < <(find scripts -maxdepth 1 -type f \( -name '*.md' ! -name 'INSTALL-RUNBOOK.md' \) -print0) -fi - -git commit --quiet --amend --no-edit -AMENDED_COMMIT="$(git rev-parse HEAD)" - -# Build the annotated tag object for the amended commit, carrying the notes. -# -# `git tag` is deliberately NOT used: a worktree shares refs/tags with the main -# repository, so `git tag -f` here would move the OPERATOR'S tag — the exact -# mechanism that corrupted local v0.1.7. Worktrees isolate the index, HEAD, and -# checkout; they do NOT isolate refs. `git mktag` writes the object into the -# object store and writes no ref at all, so nothing in the operator's clone -# changes and the object can still be pushed by SHA. -# -# THE CLEANUP MODE IS LOAD-BEARING. `git tag -a -m "$NOTES"` destroys every ## -# and ### heading via git's default --cleanup=strip, EXITS 0, and warns nobody — -# a release published that way carries a flat, ungrouped, unversioned bullet -# list. mktag is verbatim by construction: it stores the body byte-for-byte. -TAG_OBJECT="$( - { - echo "object $AMENDED_COMMIT" - echo "type commit" - echo "tag ${TAG#refs/tags/}" - echo "tagger $(git config user.name) <$(git config user.email)> $(git log -1 --format=%ct HEAD) +0000" - echo - cat "$NOTES_FILE" - } | git mktag -)" - -# Assert rather than trust: the flattening failure mode is invisible otherwise. -if ! git cat-file tag "$TAG_OBJECT" | grep -q '^## \['; then - echo "error: tag annotation lost its heading structure" >&2 - echo "hint: the notes body must be stored verbatim — git strips #-leading lines" >&2 - echo " when a message goes through --cleanup=strip (the default for -m)" >&2 - exit 1 -fi -echo "ok: tag annotation preserves its heading structure" - -# --------------------------------------------------------------------------- -# Report or publish -# --------------------------------------------------------------------------- - -if [ "$DRY_RUN" = true ]; then - echo - echo "--- filtered paths ---" - for path in "${EXCLUDE_PATHS[@]}"; do - if git ls-files --error-unmatch "$path" &>/dev/null 2>&1; then - echo "FAIL: $path still present" >&2 - exit 1 - fi - if was_tracked_before "$path"; then - echo "OK: $path removed" - elif is_intentionally_absent "$path"; then - echo "OK: $path absent (declared intentionally absent)" - else - echo "FAIL: $path was never tracked in HEAD — nothing was removed" >&2 - echo "hint: this is indistinguishable from a typo. Fix the path, or add it" >&2 - echo " to INTENTIONALLY_ABSENT with a reason if the absence is deliberate." >&2 - exit 1 - fi - done - echo "--- end filtered paths ---" - echo - echo "dry-run: nothing was pushed. To publish:" - echo " run the publish:github job with CONFIRM_TAG=$TAG" -else - if [ "${CONFIRM_TAG:-}" != "$TAG" ]; then - echo "error: publishing to the public mirror requires explicit confirmation" >&2 - echo "would publish: $TAG" >&2 - echo "hint: re-run with CONFIRM_TAG=$TAG" >&2 - exit 1 - fi - - if git fetch --quiet "$GITHUB_REMOTE" main 2>/dev/null; then - git push --quiet "$GITHUB_REMOTE" "$AMENDED_COMMIT:refs/heads/main" --force-with-lease - else - echo "$GITHUB_REMOTE:main not fetchable (likely first release) — using --force" - git push --quiet "$GITHUB_REMOTE" "$AMENDED_COMMIT:refs/heads/main" --force - fi - # Push the tag OBJECT by SHA. No local ref named $TAG was created or moved, so - # the operator's clone is untouched. - git push --quiet --force "$GITHUB_REMOTE" "$TAG_OBJECT:refs/tags/$TAG" - - echo "ok: published $TAG to $GITHUB_REMOTE (filtered ${#EXCLUDE_PATHS[@]} internal paths)" - echo " the GitHub Release is created by .github/workflows/release.yml from the tag annotation" -fi - -cd "$REPO_ROOT" diff --git a/internal/release/templates/README.md b/internal/release/templates/README.md deleted file mode 100644 index c9418c8..0000000 --- a/internal/release/templates/README.md +++ /dev/null @@ -1,63 +0,0 @@ -# Portable Release Standard - -A release practice you can adopt in any git repository. It needs `make`, `git`, and [`git-cliff`](https://git-cliff.org) — no CI system, no particular forge, and no framework. - -## Adopt it - -```bash -cp cliff.toml /path/to/your/project/ -cp release.mk /path/to/your/project/ -brew install git-cliff # or see https://git-cliff.org -``` - -Then: - -```bash -make -f release.mk release-preview # what would be released; creates nothing -make -f release.mk release # changelog + annotated tag (+ forge, if any) -``` - -Nothing in either file names a specific project, so there are no values to substitute. - -## What each file does - -| File | Role | -|------|------| -| `cliff.toml` | Maps Conventional Commit types to changelog sections, and controls how the next version is derived | -| `release.mk` | The targets: `release-preview`, `release-changelog`, `release-tag`, `release-forge`, `release` | - -## The idea - -**The version is derived, never stored.** A `VERSION` file conflates two different questions — "what is the next version?" (a release-time derivation from history) and "what is the current version?" (a build-time display) — and, being hand-maintained, drifts from the tags that actually define releases. `git-cliff --bumped-version` answers the first; `git describe` answers the second. - -**Commit messages are the input.** A `fix:` commit derives a patch bump, `feat:` a minor. A commit matching no conventional type derives nothing and appears in no changelog section — so the format is not a style preference, it is the thing that determines whether work is recorded. - -**The notes ride on the annotated tag.** That makes the tag self-describing: any clone can read the release notes for a version without a changelog file, a forge API, or network access. - -## What you must decide for yourself - -1. **Pre-1.0 behavior.** `cliff.toml` ships with `breaking_always_bump_major = false`, so a `feat!:` commit derives `0.x+1` rather than `1.0.0` — reaching a major becomes a deliberate act (`git-cliff --bump major`). This is **inert** once your major version reaches 1: after that, a breaking change derives the next major normally. Delete it if you want `feat!:` to mean `1.0.0` immediately. - -2. **The `^Update:` skip parser.** Delete it in a new project. It exists as the pattern to copy when you inherit a repository whose history predates the convention: one skip parser neutralizes the changelog symptom without rewriting history. - -3. **Whether to commit `CHANGELOG.md`.** The `release` target writes it and leaves it uncommitted for review. Because the notes also travel on the tag, a committed changelog is a convenience rather than a requirement — which means you can skip the CI commit-back machinery (protected-branch push rights, `[skip ci]` loop guards) entirely. - -4. **Compare links.** Deliberately not generated. If your published tags are ever a subset of your internal tags, a compare link between two adjacent changelog versions is a 404 by construction. Tags and forge Release objects are the durable record. Add a `footer` to `cliff.toml` if you publish every tag and want them. - -## Behavior when there is no forge - -`release-forge` **skips rather than fails**: - -- No `origin` remote → the tag is created locally and you are told to push it yourself later. -- Remote but no `gh`/`glab` CLI → the tag is pushed; no Release object is created, and you are told the annotation already carries the notes so a Release can be made later. -- `NO_PUSH=1` → nothing is pushed. - -`make -f release.mk release` therefore succeeds in a repository with no remote at all. A release target that failed in that case would be unusable rather than merely limited. - -## The one hazard worth knowing - -`git tag -a -m "$NOTES"` **destroys every Markdown heading** in the notes. Git's default `--cleanup=strip` treats `#`-leading lines as comments, deletes them, **exits 0**, and warns nobody — so the tag looks fine and carries a flat, ungrouped, unversioned bullet list. - -`release.mk` writes the annotation with `--cleanup=verbatim -F ` and then asserts the headings survived, deleting the tag if they did not. Keep both halves if you adapt it: the assertion is what makes the failure visible. - -Reading the body from a file rather than a shell variable also avoids quoting problems with the backticks and asterisks that appear in real release notes. diff --git a/internal/release/templates/cliff.toml b/internal/release/templates/cliff.toml deleted file mode 100644 index 51e9e2c..0000000 --- a/internal/release/templates/cliff.toml +++ /dev/null @@ -1,77 +0,0 @@ -[changelog] -header = """# Changelog - -All notable changes to this project will be documented in this file. - -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -""" - -body = """ -{% if version -%} -## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }} -{% else -%} -## [Unreleased] -{% endif -%} -{% for group, commits in commits | group_by(attribute="group") %} -### {{ group | upper_first }} -{% for commit in commits -%} -- {% if commit.scope %}**{{ commit.scope }}** — {% endif %}\ -{{ commit.message | split(pat="\n") | first | upper_first | trim }} -{% endfor %} -{% endfor -%} -""" - -# No footer. Per-version compare links are deliberately not generated: -# -# 1. Under selective publication, gaps in the public tag sequence are routine, -# so a link between two versions where only one was published is a 404 by -# construction. This repository already published one (v0.1.5). -# 2. Hardcoding a forge URL here makes any copy of this config emit links to -# the original project's repository. -# -# Tags and forge Release objects are the durable, navigable record. - -trim = true - -[git] -conventional_commits = true -filter_unconventional = false -split_commits = false -sort_commits = "oldest" -# Strict semver only. A permissive pattern lets a stray prerelease-shaped tag -# (v0.9.9-rc1) become the derivation base — verified. -tag_pattern = "^v[0-9]+\\.[0-9]+\\.[0-9]+$" -protect_breaking_commits = false -commit_parsers = [ - # OPTIONAL — delete this line in a new project. - # - # It exists because the repository this template came from had a run of - # auto-generated "Update: modify N file(s)" commits that would otherwise fill - # the changelog with diffstat noise. A fresh project has no such history. - # - # Kept here as the pattern to copy when you inherit a repository whose - # history predates the convention: one skip parser neutralizes the changelog - # symptom without rewriting history. - { message = "^Update:", skip = true }, - { message = "^feat", group = "Added" }, - { message = "^fix", group = "Fixed" }, - { message = "^docs", group = "Documentation" }, - { message = "^perf", group = "Performance" }, - { message = "^refactor", group = "Changed" }, - { message = "^ci", group = "CI/Build" }, - { message = "^build", group = "CI/Build" }, - { message = "^revert", group = "Reverted" }, - { message = "^style", skip = true }, - { message = "^test", skip = true }, - { message = "^chore", skip = true }, -] - -[bump] -# Pre-1.0 guard: a `feat!:` commit yields 0.x+1 rather than 1.0.0. Reaching 1.0.0 -# is a deliberate act via the release:major job, not something ordinary derivation -# can stumble into. -# -# INERT once the major is >= 1 — verified: at v1.0.0 a `feat!:` still derives -# v2.0.0. This is pre-1.0 scaffolding, not a permanent invariant. -breaking_always_bump_major = false diff --git a/internal/release/templates/release.mk b/internal/release/templates/release.mk deleted file mode 100644 index e22e488..0000000 --- a/internal/release/templates/release.mk +++ /dev/null @@ -1,117 +0,0 @@ -# Release automation — forge-agnostic reference implementation. -# -# Works with `make`, `git`, and `git-cliff` alone. No CI system, no forge, and no -# framework required. Any forge-specific step is SKIPPED rather than failed when -# the forge is absent, so this works in a repository with no remote at all. -# -# Usage: -# make -f release.mk release-preview # what would be released; creates nothing -# make -f release.mk release # changelog + annotated tag -# make -f release.mk release VERSION=1.2.3 # explicit override -# -# Requires: git-cliff (https://git-cliff.org) — `brew install git-cliff` - -.PHONY: release release-preview release-changelog release-tag release-forge - -# The version is DERIVED from Conventional Commit history, not stored in a file. -# A version file conflates two different questions — "what is next?" (a -# release-time derivation) and "what is current?" (a build-time display) — and -# being hand-maintained, it drifts from the tags that actually define releases. -VERSION ?= $(shell git-cliff --bumped-version 2>/dev/null | sed 's/^v//') -TAG = v$(VERSION) -LATEST_TAG = $(shell git describe --tags --abbrev=0 2>/dev/null) - -# Where the notes body is staged. Kept in a file rather than a shell variable: -# the content is multi-line and contains backticks and asterisks that shell -# quoting mangles. -NOTES_FILE ?= .release-notes.md - -define require_releasable - @command -v git-cliff >/dev/null 2>&1 || { \ - echo "error: git-cliff is not installed" >&2; \ - echo "hint: brew install git-cliff (or see https://git-cliff.org)" >&2; \ - exit 1; \ - } - @if [ -z "$(VERSION)" ]; then \ - echo "error: could not derive a version from commit history" >&2; \ - echo "hint: are there any commits? Is cliff.toml present?" >&2; \ - exit 1; \ - fi - @if [ "$(TAG)" = "$(LATEST_TAG)" ]; then \ - echo "Nothing to release — derived version equals the current tag ($(LATEST_TAG))."; \ - echo "Only conventional commits (feat:, fix:, ...) derive a new version."; \ - exit 1; \ - fi -endef - -# Print what a release would do. Creates nothing, so it is safe to run always. -release-preview: - @echo "current release: $(if $(LATEST_TAG),$(LATEST_TAG),none)" - @echo "would release: $(TAG)" - @if [ "$(TAG)" = "$(LATEST_TAG)" ]; then \ - echo ""; \ - echo "Nothing to release — no commits since $(LATEST_TAG) affect the version."; \ - else \ - echo ""; \ - echo "--- release notes for $(TAG) ---"; \ - git-cliff --unreleased --strip all; \ - echo "--- end release notes ---"; \ - fi - -# Regenerate the committed changelog through the derived version. -release-changelog: - $(call require_releasable) - @git-cliff --tag "$(TAG)" -o CHANGELOG.md - @echo "ok: wrote CHANGELOG.md through $(TAG)" - -# Create the annotated tag, carrying the release notes on the annotation. -release-tag: - $(call require_releasable) -# --tag stamps the heading as "## [X.Y.Z] - ". Without it the notes are -# headed "## [Unreleased]", which is wrong on a tag that names a version. - @git-cliff --unreleased --tag "$(TAG)" --strip all > $(NOTES_FILE) - @if [ ! -s $(NOTES_FILE) ]; then \ - echo "error: generated notes are empty" >&2; rm -f $(NOTES_FILE); exit 1; \ - fi -# --cleanup=verbatim is LOAD-BEARING. Without it, git's default --cleanup=strip -# treats every #-leading line as a comment and silently deletes all Markdown -# headings from the annotation. It exits 0 and warns nobody. - @git tag -a "$(TAG)" --cleanup=verbatim -F $(NOTES_FILE) -# Assert rather than trust: the failure above is otherwise invisible. - @git tag -l --format='%(contents)' "$(TAG)" | grep -q '^## \[' || { \ - echo "error: tag annotation lost its heading structure" >&2; \ - echo "hint: --cleanup=verbatim is required" >&2; \ - git tag -d "$(TAG)" >/dev/null; rm -f $(NOTES_FILE); exit 1; \ - } - @rm -f $(NOTES_FILE) - @echo "ok: created annotated tag $(TAG)" - -# Push the tag and create a forge Release, IF a forge is configured. -# -# SKIPPED, not failed, when absent: a solution may have no forge of any kind, and -# a release target that fails in that case is unusable rather than merely limited. -release-forge: - @if ! git remote get-url origin >/dev/null 2>&1; then \ - echo "skip: no 'origin' remote configured — tag created locally only"; \ - echo " (push it yourself when a remote exists: git push $(TAG))"; \ - elif [ -n "$(NO_PUSH)" ]; then \ - echo "skip: NO_PUSH set — tag created locally only"; \ - else \ - git push origin "$(TAG)" && echo "ok: pushed $(TAG) to origin"; \ - if command -v gh >/dev/null 2>&1 && gh repo view >/dev/null 2>&1; then \ - gh release create "$(TAG)" --notes "$$(git tag -l --format='%(contents)' $(TAG))" \ - && echo "ok: created GitHub Release $(TAG)"; \ - elif command -v glab >/dev/null 2>&1 && glab repo view >/dev/null 2>&1; then \ - glab release create "$(TAG)" --notes "$$(git tag -l --format='%(contents)' $(TAG))" \ - && echo "ok: created GitLab Release $(TAG)"; \ - else \ - echo "skip: no forge CLI available (gh/glab) — tag pushed, no Release object"; \ - echo " the annotated tag carries the notes, so a Release can be made later"; \ - fi; \ - fi - -# The whole flow. Changelog and tag always; forge steps only if a forge exists. -release: release-changelog release-tag release-forge - @echo "" - @echo "Released $(TAG)." - @echo "The CHANGELOG.md change is uncommitted — review and commit it." diff --git a/scripts/.gitignore b/scripts/.gitignore deleted file mode 100644 index 8855864..0000000 --- a/scripts/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -# IPA generated Makefiles & security disposition (solution-specific, not committed) -*.mk -SECURITY-DISPOSITION.md -README.md diff --git a/web-client/package-lock.json b/web-client/package-lock.json index 4225c23..4ec624d 100644 --- a/web-client/package-lock.json +++ b/web-client/package-lock.json @@ -1988,9 +1988,9 @@ "license": "MIT" }, "node_modules/@hono/node-server": { - "version": "1.19.14", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", - "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "version": "1.19.17", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.17.tgz", + "integrity": "sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==", "dev": true, "license": "MIT", "engines": { @@ -16206,23 +16206,6 @@ "dev": true, "license": "ISC" }, - "node_modules/yaml": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", - "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", - "license": "ISC", - "optional": true, - "peer": true, - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",