Skip to content

Git apps that follow tags - #5

Merged
inkly merged 22 commits into
mainfrom
feat/git-tags
Sep 23, 2026
Merged

inkly merged 22 commits into
mainfrom
feat/git-tags

Conversation

@inkly

@inkly inkly commented Sep 23, 2026

Copy link
Copy Markdown
Collaborator

A git app can follow the highest semver tag of its repository instead of a branch head: optional glob pattern, pre-releases off by default, upgrades only (a deleted, narrowed or moved tag never makes it go back or redeploy by itself), any eligible tag deployable by hand, mode switchable later. GET /git/{app}/tags, deploy {tag}, backups record the mode and tag. Spec and plans in docs/superpowers/.

pkg/git learns what an app that follows tags needs: the remote's tags with the commit each names (peeled lines win, names git would misread are dropped), a fetch of one tag or of one commit by its hash, detaching and attaching the work tree without moving a file, and kept refs under refs/recasaos/kept/ so a commit a revert needs is never collected. validGitBranch now delegates to git.ValidRefName.
A git app's state records what it follows (branch or tags), its tag pattern and whether pre-releases count, and the tag each deployment, history entry, operation and check stands for. The eligible tags are strict semver after one leading v, filtered by the pattern, highest first with ties by name. The view shows the mode, the tags, and a deployed tag the remote moved; new_commits in tag mode only lights for a higher tag.
Registration and PUT accept follow, tag_pattern and prereleases. An app that follows tags has no branch. Changing the mode of a cloned app detaches its folder at its commit, or puts it back on the given branch or the remote's default, without moving a file. The tag filter is kept across switches.
A cloned app that follows tags and goes back to branches without naming one reads the remote's default branch. Test that a remote naming an invalid branch (-bad), and a remote that cannot be read, both answer 400 and leave the app following tags with its folder detached, and that giving the branch needs no remote.
…its tags

The check of an app that follows tags lists the remote's tags once, records the highest eligible one beside its commit, and never asks for the default branch. A first clone is made at that tag, detached. With no eligible tag, the check says so under which filter, keeps what it saw last, and the app stays idle. GitAppTags gives the eligible tags, highest first, fifty at most.
A hostile remote can advertise a tag name with a newline in it. ls-remote then prints the rest of the name as a line of its own, so a strict semver tag could name any string as its commit, and that string would become the deploy target. LsRemoteTags now keeps only lines whose hash is 40 lowercase hex, which every caller goes through. A test reproduces the attack with a fake upload-pack reached through ext::.
An app that follows tags deploys by hand the highest eligible tag, any tag named, lower ones included, or a commit of its history under the tag it ran as. A tag whose commit ran is a revert, the running version again is a repair, and an older tag pauses the automatic deployment. Before a build the tag is fetched alone, and a tag that moved since the check fails the deployment. The folder moves with reset --keep, and the first branch deployment after tags skips the descent rule. The operation, the history and an interrupted entry keep the tag.
…he history

An app that follows tags is deployed automatically only to a tag strictly higher than the one it runs, and only once a tag was deployed in that mode; a branch app only once a version of its branch runs. A deleted, lower or moved tag deploys nothing by itself. The folder keeps a ref per version the history offers (refs/recasaos/kept/<commit>), reconciled after every deployment and at startup recovery, and a version whose commit git no longer holds is no longer offered as a revert.
A tag-mode app's backup origin records its mode, the tag its commit was deployed as, and its tag filter, all omitted for a branch app so that its manifest is what older versions wrote. A restore clones such an app at the backup's tag, else at the highest eligible tag, else at the remote's default branch, detaches the folder, and fetches the backed-up commit by its hash when the clone does not hold it; a forge that refuses fails the restore and nothing else is deployed. The restore's deployment builds that commit without checking where its tag went.
The restore test passed even when the clone ignored the backup's tag and took the highest eligible one, since v1.1.0 contains v1.0.0's commit. It now checks that the clone does not hold the v1.1.0 commit, which a single-branch clone at v1.0.0 never fetches.
The OpenAPI schema declares follow, tag_pattern and prereleases on the app, its registration and its update, the tag beside the commit on deployed, the check, the operation and the history, and remote_tag and tag_moved on the check. GET /git/{app}/tags answers the eligible tags of the remote, and deploy takes a tag instead of a commit, both being a 400. No enum is added: the codegen still has 34 enum constants.
A release candidate promoted as is (v2.0.0 tagged on the commit v2.0.0-rc.3 deployed) kept its old name: the dashboard showed v2.0.0 as newer than what runs, and a later move of v2.0.0 was deployed automatically as an upgrade from rc.3. The check now records the higher tag on deployed and on its history entry without deploying anything, so a later move of that tag is reported as tag_moved.
In tag mode a manual deployment of a version that ran counted as a revert and paused automatic deployment, even when it went back to the newest version. Reverting v1.4.2 to v1.4.1, then deploying v1.4.2 by hand, left v1.5.0 waiting, contrary to the dashboard's promise that a manual deployment resumes it. In tag mode the pause is now set only by a tag lower than the deployed one; the same or a higher tag, a revert included, clears it. Branch mode keeps its rule.
A check of one mode says nothing about the other. After a switch from tags to a branch the dashboard read the tag check as a branch check and lit 'New commits' for the newest tag's commit; after a switch to tags it showed the old check's time beside 'Not checked yet'. A PUT that changes follow now clears the last check, so the app reads as not checked yet in its new mode.
Going back to a branch put the folder on it with git checkout -B, which silently resets a branch that already exists. On an adopted folder whose tag deployments moved the detached HEAD away, unpushed commits on the owner's branch dropped out of it and survived only in the reflog. followGitFolder now answers 400 '<branch> holds commits the folder is not on: check it out there first' when the local branch exists and is not an ancestor of HEAD.
A PUT that changed the access and went back to a branch wrote or removed the .token file before asking the remote for its default branch; a remote that could not be read then returned 400 before the state was saved, leaving the files and the state out of step. The branch to follow, the remote's default read with the access the request gives, and the local-branch check now run before any access file is written; the folder moves afterwards.
An app registered to follow tags and cloned at a tag off its default branch, never deployed, then switched back to the branch, could never deploy there: the folder sat on the tag's commit and every branch deployment failed to fast-forward. With no previous deployment the move now uses reset --keep; fetchGitBranch has already checked that the target is on the branch.
…lost remote

Two paths the spec and the plan name had no test: a failed start in tag mode moves the detached folder back to the previous commit, which runs again under its tag with the outcome rolled_back; and GET .../tags answers 400 'the repository cannot be reached' when the remote is gone.
Back on a branch, a cloned app reads the remote's default during the PUT with the access the request gives, before any access file changes, and a remote that cannot be read is a 400 (plan decision 1). A local branch holding commits the folder is not on is refused; a change of mode clears the check; a higher tag on the running commit is recorded without a deployment; in tag mode only a lower tag pauses automatic deployment; the first deployment of all moves with reset --keep. Adds the 'Clarifications made while planning' section that lists them, and the matching tests.
A change of mode clears the last check; the compose file that check found missing belongs to it, at the other mode's ref. Without this, an app switched after a clone that found no compose file showed the compose example beside a check that says 'not checked yet'. NoComposeFile is now cleared with the check.
@inkly
inkly merged commit 9dffcf5 into main Sep 23, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant