Reviewed-on: #32
RepoFoundry
RepoFoundry (src/create-project.sh) sets up a new project in one run:
- a Gitea repository (the source of truth),
- optionally an empty GitHub repository that receives everything through a push mirror from Gitea to GitHub,
- and a local project with credential-free remotes and the SQA-QC-Framework added as a git submodule, with its skills, git hooks (and optionally the plan gate) and templates installed.
It is a Bash script. It asks for the repository name, description, visibility
and owner (a user or an organization, separately on each host), shows a plan,
and only creates anything after you pass --apply and answer yes.
Status. The script is tested with stubbed host APIs and real git against local repositories (see Development). A first end-to-end run on real GitHub and Gitea repositories, with organization owners on both, has passed (2026-10-05, review record RC-017). Creating a repository under your own Gitea account needs the
write:usertoken scope (see Token permissions); that path has not been completed yet.
Contents
- Installation
- Configuration
- Usage
- SSH access to Gitea
- Token permissions
- Security decisions
- Error handling and recovery
- Known limitations
- Code layout
- Development
- Stakeholders
- License
Installation
Requirements:
| Tool | Needed for |
|---|---|
| bash 4.4 or later | the script (macOS ships 3.2: install a newer bash first) |
git |
the local project and the framework submodule |
curl |
the GitHub and Gitea APIs |
mktemp and the usual base tools |
temporary files and small helpers |
ssh (optional) |
the SSH check; without it the framework steps are skipped |
jq (optional) |
JSON parsing; without it a small built-in reader is used |
git clone https://git.tirsystem.com/TirSystem-BashScript/RepoFoundry.git
cd RepoFoundry
src/create-project.sh --help
Nothing has to be installed system-wide: the script runs from the checkout and
loads its own files from src/lib/.
Configuration
The script reads two plain files from the project root. They are parsed,
never executed (source is not used): only KEY=VALUE lines with known keys
are accepted, and anything else stops the run with a message that names the key
and the line, never the value.
cp config.env.example config.env # service addresses, not secret: set GITEA_URL (and GITEA_API_URL)
cp .env.example .env # credentials: keep private
chmod 600 .env # Linux and macOS
config.env (service addresses)
| Key | Meaning | Default |
|---|---|---|
GITHUB_API_URL |
GitHub REST API base URL | https://api.github.com |
GITHUB_WEB_URL |
GitHub web base URL (links and the mirror address) | https://github.com |
GITEA_URL |
Gitea base URL (required) | |
GITEA_API_URL |
Gitea REST API base URL | GITEA_URL + /api/v1 |
GITEA_SSH_PORT |
SSH port of the Gitea server | 10022 |
MIRROR_INTERVAL |
how often Gitea pushes to GitHub, e.g. 10m0s |
10m0s |
FRAMEWORK_REPO |
OWNER/NAME of the framework on Gitea |
TirSystem/SQA-QC-Framework |
Every URL must start with https:// and must not contain a user name,
password, query string or fragment. A credential key in this file is rejected.
.env (credentials)
| Key | Meaning |
|---|---|
GITEA_TOKEN |
Gitea access token (required) |
GITHUB_PAT |
GitHub personal access token (only when you choose GitHub) |
GITHUB_USER |
the GitHub account the token belongs to; only a default for the owner prompt |
.env is ignored by git. The script warns if it is readable by other users or
not ignored by git. See Token permissions for what each
token needs.
Usage
src/create-project.sh # dry run: reads from the hosts, creates nothing
src/create-project.sh --apply # creates everything after a final yes
src/create-project.sh --config /path/to/config.env --env /path/to/.env
The script asks for, in this order: repository name, description, visibility, Gitea owner, whether to also create a GitHub repository (and its owner), the local directory and whether to enable the plan gate. It then checks both hosts with read-only requests and prints a plan:
Plan:
Gitea repository : create (private) with the AGPL-3.0 license https://git.example.org/Team/my-app
GitHub repository : create (private), empty https://github.com/acme/my-app
Push mirror : Gitea -> GitHub every 10m0s
Local project : create ./my-app (new directory), git on main, no commit
Local origin : will use SSH (the SSH test passed)
Framework : add ssh://git@git.example.org:10022/Team/SQA-QC-Framework.git as a submodule
Skills and hooks : install once; plan gate no
Templates : AGENTS.md and docs/artifact-registry.md (you are asked before a file is replaced)
Without --apply that is all that happens. With --apply the script asks
"Create these now" (default no) and then creates, in this order:
- the GitHub repository (empty), if chosen;
- the Gitea repository (with the AGPL-3.0 license if GitHub was chosen);
- the push mirror Gitea -> GitHub, and a request for its first sync;
- the local directory,
git initonmain, theoriginremote (andgithubif chosen), and, if the Gitea repository holds the license commit, that history; - the framework as the submodule
framework; - the framework's skills and git hooks, and the plan gate if chosen;
AGENTS.mdanddocs/artifact-registry.mdfrom the framework's templates.
No commit is made in the new project. Work on a branch there: the framework's
hooks refuse commits on main.
Choices
- GitHub or not. Choosing GitHub also applies the AGPL-3.0 license to the
Gitea repository (so it is not empty) and sets up the mirror. Without GitHub
the Gitea repository is empty and has no license, and
GITHUB_PATis not needed. - Owners. The Gitea owner and the GitHub owner are chosen separately and
may be a user or an organization.
GITHUB_USERis only the suggested default for the GitHub owner prompt; it identifies who authenticates. - Plan gate. If enabled, a commit that changes
src/ortests/in the new project needs aTask: MIL-NNN#Ntrailer.
Nothing is overwritten without a yes
The script asks first (default no) before it uses an existing directory, before
it replaces an existing core.hooksPath, and before it replaces an existing
AGENTS.md or docs/artifact-registry.md. It never deletes anything, never
replaces a remote that points somewhere else, and git itself refuses to
overwrite a file when the license history is checked out.
SSH access to Gitea
The framework submodule is fetched over SSH on port 10022
(ssh://git@<gitea host>:10022/TirSystem/SQA-QC-Framework.git). Before you run
the script:
-
Add your SSH public key to your Gitea account.
-
Connect once by hand so that the server's host key is known (the script refuses unknown host keys and never answers questions for you):
ssh -p 10022 -T git@git.tirsystem.comA message that you have successfully authenticated, without shell access, means it works.
The script runs the same check in its dry run. If it fails, the plan says so
and, with --apply, you are asked whether to create the repositories and the
local project without the framework steps (they are then reported as
skipped). The default answer is no.
Token permissions
Both tokens go in .env (never in config.env, never in a remote URL). The
script sends them only in a request header, through a private temporary file,
and never prints them.
GitHub token (GITHUB_PAT, only when you choose GitHub)
The same token has two jobs: it creates the repository, and it is the password Gitea uses to push the mirror. It therefore needs to create repositories for the chosen owner and to push to the new one.
| Need | Token | Source |
|---|---|---|
| Create a private repository | classic token with the repo scope |
GitHub REST documentation, "Create a repository" |
| Create a public repository only | classic token with public_repo is enough |
same |
| Push from the Gitea mirror | covered by repo |
|
| Check that you belong to the organization owner | worked with a classic token that has repo and admin:org |
read:org alone was not tested: the GitHub documentation names no scope for this call. If the script says you do not belong to an organization that you do belong to, add read:org. |
- Organization owners: you must be an active member who is allowed to create repositories in the organization. Organizations that require SSO or approval of tokens need the token authorised first.
- Fine-grained tokens: the GitHub documentation lists no fine-grained permission for creating a repository, and this has not been tested. Use a classic token until it has been.
GITHUB_USER: if it differs from the account the token belongs to, the script warns and uses the account the token belongs to.
Gitea token (GITEA_TOKEN)
| Need | Scope | Source |
|---|---|---|
| Read the account the token belongs to | read:user |
Gitea documentation |
| Create a repository under your own account | write:user |
Confirmed by a real server: without it Gitea answers required=[write:user] |
| Create a repository in an organization, manage its push mirror | write:organization and write:repository |
worked with a token that has both, plus read:user; the minimum was not narrowed down |
| Look up an organization and your permissions in it | covered by the scopes above | worked in the end-to-end run |
A missing scope shows up as an HTTP 403 with the server's own message. The script stops before it creates anything when a preflight check is refused.
Security decisions
- Tokens never appear in output, logs, remote URLs,
.git/config,.gitmodules, command lines or leftover files. They go tocurlthrough a private configuration file that is removed right after the request, and togit(HTTPS fetch only) through aGIT_ASKPASShelper and the environment of that one command. Output is filtered, so even a server message that echoes a token is shown as[redacted]. Tests plant fake tokens and search all output and every file of the new project for them. - No
set -x. Tracing would print every secret, so the script switches it off and says so. - Config files are parsed, not sourced, with a whitelist of keys; values
are validated (URLs must be
httpswithout credentials, tokens must have a safe character set) and never executed. - Dry run by default. Creating anything needs
--applyand a final yes. - No destructive commands. The script never deletes a repository or a file and never uses a recursive delete; temporary files are removed one by one.
- Credential-free remotes.
originisssh://git@host:port/owner/name.git(or plain HTTPS when SSH is not used) andgithubis a plain HTTPS address. - Redirects are not followed, so a token is only ever sent to the host in the URL it was meant for. Unknown SSH host keys are refused.
- Framework scripts run on the new project only. They are run with
PROJECT_ROOTset explicitly, so aPROJECT_ROOTin your environment cannot point them elsewhere. They come from the framework repository you configured: review what you trust there.
Error handling and recovery
Every message starts with error:, names what failed and what to do, and never
contains a secret. Exit codes: 0 success (or a dry run), 1 a failed check or
step, 2 a usage error.
| What happens | What the script does | What you do |
|---|---|---|
| A tool, a config key or a token is missing or invalid | stops before any request | fix it and run again |
| A token is refused, an owner is unknown, a name is taken, the license is missing | stops in the preflight; nothing was created | fix the cause |
| The host cannot be reached | stops with the host name | try again |
| A repository already exists and is empty (Gitea: or holds only the license and the README Gitea adds) | offers to reuse it (default no) | answer, or choose another name |
| A repository already has content | stops | choose another name or remove it |
| A step fails after another succeeded | stops and prints what exists, what failed and how to continue | fix the cause and run the same command again with --apply: what was created is offered for reuse |
| The mirror is refused (disabled, interval too short) | keeps the repositories and reports it | change MIRROR_INTERVAL or ask the Gitea administrator, then run again |
| The framework submodule cannot be fetched | reports the address and how to test SSH | fix your SSH access, run again |
sync_on_commit was ignored by Gitea |
warns; the mirror syncs on its interval | enable it in the repository settings if needed |
A partial run is reported like this:
The run stopped before it finished. This is what exists now:
GitHub repository : created https://github.com/acme/my-app
Gitea repository : FAILED
Push mirror : not attempted
...
To continue: fix the problem named above and run the same command again with --apply.
Nothing is deleted automatically. To start over, delete the repositories in the web interface and the project directory by hand.
Known limitations
- The mirror password is stored on the Gitea server. Gitea needs the GitHub token to push, so it keeps it. How it is protected depends on the Gitea version and its administrators. Use a token that is only meant for this, and revoke it if the Gitea server is ever in doubt.
sync_on_commitmay be ignored. When a push mirror is created through the API, some Gitea versions ignoresync_on_commit(upstream issue go-gitea/gitea#22990). On Gitea 1.27.3 it was applied: a branch pushed to Gitea reached GitHub within seconds. The script reads the mirror back and warns if the setting was not applied; the mirror then syncs on its interval (MIRROR_INTERVAL, default 10 minutes). The first sync is requested right after the mirror is created.- The server decides the shortest interval and whether push mirrors are allowed at all. A refused mirror stops the run with the server's message; the repositories created so far are kept.
- The license commit. Gitea adds the license file when the repository is
created with
auto_init, and on the real server it also adds a generatedREADME.md. Both are mirrored to GitHub and become the first commit of the local project. A Gitea repository that holds only these files counts as content this script created and is offered for reuse; a repository with anything else counts as having content and is refused. - No rollback. See Error handling and recovery.
- Mirror direction is Gitea to GitHub only. Push to Gitea; GitHub is a copy.
- The framework needs SSH. Without SSH access to Gitea the framework steps can only be skipped.
- Tested on Windows (Git Bash) only so far. Running the tests on Linux and macOS is an open follow-up.
Code layout
src/create-project.sh is the entry point: the header, strict mode, loading
and main. The work is split by responsibility into src/lib/, one job per
file. The files are loaded from that directory only, by a fixed path.
| File | Responsibility |
|---|---|
constants.sh |
constants and the shared state of a run |
output.sh |
messages for the user and redaction of secrets |
temp.sh |
private temporary files and their cleanup |
util.sh |
small string and list helpers |
validate.sh |
validators for names, URLs, tokens, ports, intervals |
config.sh |
reading and checking config.env and .env (never sourced) |
tools.sh |
checking the required tools |
json.sh |
the little JSON the script reads and writes |
http.sh |
the one place that runs curl; tokens stay off the command line |
api.sh |
GitHub and Gitea API calls and reporting a refused call |
prompts.sh |
interactive questions with validation |
project.sh |
the project details: asking for them and showing them |
hosts.sh |
names, links and remote addresses of the repositories |
preflight.sh |
read-only checks of both hosts |
steps.sh |
the outcome of each step and the final report |
plan.sh |
printing what the script is about to do |
repositories.sh |
creating the GitHub and Gitea repositories |
mirror.sh |
the Gitea to GitHub push mirror |
git.sh |
running git for the new project without prompts or tokens on a command line |
localproject.sh |
the local directory, git repository and remotes |
framework.sh |
the framework submodule, skills, hooks and templates |
apply.sh |
confirmations and the apply flow; the only code that changes anything |
cli.sh |
usage text and option parsing |
Each file names its responsibility and lists the functions it provides in its
first lines. tests/test-structure.sh keeps it that way: every file in
src/lib/ is loaded, no function is defined twice, and a file does nothing
when it is loaded.
Development
bash tests/run-tests.sh # shellcheck, shfmt and all tests, no network
bash tests/run-tests.sh PATTERN # only tests whose name contains PATTERN
The tests stub the two host APIs (a fake curl answers from a routes file) and
ssh, and run real git against local bare repositories that stand in for
Gitea and the framework (git's insteadOf rewrites the remote addresses), with
a private git configuration. Nothing reaches the network and nothing outside
the test directories is changed. Mutation checks show that the tests fail when
a guarantee is removed.
Planning documents, reviews and the traceability matrix are in docs/; the
project follows the SQA and QC framework (see AGENTS.md).
Stakeholders
| Who | Role |
|---|---|
| Tirsvad | Product Owner and maintainer |
| Michael Kragh | DevOps, cybersecurity and maintainer |
| GitHub readers | people who read and may reuse this project |
License
GNU Affero General Public License v3.0; see LICENSE.