You spend three months building a library that solves a real problem. Complete tests, documentation, usage examples. You publish it on GitHub and NPM. During the first week it’s at the top of your profile because it’s the most recently updated. You get some stars, forks, issues. Everything’s going well.
Then you hotfix a typo in a repo from two years ago. Or you create a temporary repo to test something. Or you simply update the README of another project. You go back to your GitHub profile and your star project is no longer visible. It’s buried under repos that nobody visits.
The problem is that GitHub sorts repositories by last updated date. Not by stars, not by forks, not by importance. The repo you touched five minutes ago appears first. The one that hasn’t had commits in three weeks disappears from the radar even if it has a thousand stars.
For anyone who uses GitHub as a showcase, this is a problem. A developer job hunting wants their best projects visible when a recruiter looks at their profile. A company maintaining public repos wants their main products to appear first, not the CI configuration repo they touched yesterday. An open source maintainer wants to highlight active libraries, not experimental forks.
Yes, GitHub has the “pinned repositories” option that lets you pin up to six repos on your profile. But only six. And pinned repos don’t control the order of the rest of the repos that appear below. If you have twenty relevant projects, the fourteen that don’t fit in pinned still sort by date. I wanted full control over the order, not just the first six.
The obvious solution doesn’t work
The first idea is to simply touch the important repos from time to time. Open each one, edit something, make a commit. But this has obvious problems: it’s manual, tedious, and if you have ten important projects, it means ten garbage commits every time you want to reorder.
The second idea is to automate somehow. But GitHub doesn’t have an API to change repo order directly. The order is derived from the last updated date, period.
The trick: empty commits
What you can do is update the “last updated” timestamp without modifying code. Git allows empty commits with the --allow-empty flag. An empty commit doesn’t change any files, but it does update the repo’s date.
git commit --allow-empty -m "bump"
git push
This commit appears in the history but has no diff. The repo now has a last updated date of one second ago, so it moves to the top of the list.
The problem is that the history fills up with “bump” commits that mean nothing. To solve this, you can revert immediately:
git commit --allow-empty -m "gitpins: bump"
git revert HEAD --no-edit
git push
Now you have two commits: an empty one and its revert. The history stays clean because the final state is identical to the initial. But the timestamp was updated.
Automation with GitHub Actions
Doing this manually every few hours makes no sense. The real solution is a GitHub Actions workflow that runs periodically and calls an endpoint that does the work.
The flow is:
- You create a configuration repository (
gitpins-config) - That repo contains a workflow that runs every N hours
- The workflow has a secret that identifies your configuration
- When it runs, it makes an HTTP call to the sync endpoint
- The endpoint iterates over your repos and makes empty commits in reverse order
- The last repo processed gets the most recent timestamp, so it appears first
The actual YAML workflow that GitPins generates:
name: GitPins - Maintain Repo Order
on:
schedule:
- cron: '0 */6 * * *' # Every 6 hours (configurable)
workflow_dispatch: # Manual trigger
jobs:
sync:
runs-on: ubuntu-latest
steps:
- name: Trigger GitPins Sync
run: |
curl -s -X POST "https://gitpins.vercel.app/api/sync/${{ secrets.GITPINS_SYNC_SECRET }}" \
-H "Content-Type: application/json" \
-o response.json
echo "Response:"
cat response.json
if grep -q '"success":true' response.json; then
echo "Sync completed successfully!"
else
echo "Sync may have had issues, check response above"
fi
The cron frequency is configurable: every 1, 2, 4, 6, 8, 12, or 24 hours. The GITPINS_SYNC_SECRET secret is a UUID that GitPins generates automatically and configures in the repo.
Why I built GitPins
Implementing this manually is feasible but tedious. You have to create the config repo, write the YAML workflow, generate the token with the correct permissions, configure the secrets, and keep the repo list updated.
GitPins automates all of this with a web interface. You connect with GitHub OAuth, see your repos in a list, drag them to the order you want, and activate sync. GitPins creates the configuration repo with the workflow automatically, generates the necessary secret, and configures everything.
The dashboard lets you:
- See all your public and private repos
- Order with drag & drop
- Choose the commit strategy (revert or branch)
- Configure sync frequency (1, 2, 4, 6, 8, 12, or 24 hours)
- Include or exclude private repos
- See the status of the last sync
Technical architecture
GitPins is built with Next.js 15 using App Router. Authentication uses GitHub OAuth through a GitHub App, which allows requesting granular permissions.
src/
├── app/
│ ├── api/
│ │ ├── auth/ # OAuth flow
│ │ ├── repos/ # List and order repos
│ │ ├── config/ # Create config repo
│ │ └── sync/ # Endpoint called by the Action
│ ├── dashboard/ # Main UI
│ └── admin/ # Admin panel
├── lib/
│ ├── crypto.ts # AES-256-GCM for tokens
│ ├── session.ts # JWT sessions
│ ├── github.ts # OAuth helpers
│ └── github-app.ts # GitHub App operations
The database is PostgreSQL with Prisma. It stores users, their order preferences, encrypted tokens, and sync logs.
GitHub tokens are encrypted with AES-256-GCM before being saved. The encryption key is in an environment variable, never in the code or database.
The two commit strategies
GitPins supports two strategies for updating the timestamp. Both use GitHub’s Git API directly, without needing to clone repos:
Revert Strategy (recommended):
// Create empty commit
const { data: newCommit } = await octokit.rest.git.createCommit({
owner,
repo,
message: `[GitPins] Position: ${position}/${total}`,
tree: commitData.tree.sha, // Same tree = no changes
parents: [sha],
})
// Update reference
await octokit.rest.git.updateRef({
owner,
repo,
ref: `heads/${defaultBranch}`,
sha: newCommit.sha,
})
// Revert immediately
const { data: revertCommit } = await octokit.rest.git.createCommit({
owner,
repo,
message: '[GitPins] Revert',
tree: commitData.tree.sha, // Back to original tree
parents: [newCommit.sha],
})
await octokit.rest.git.updateRef({
owner,
repo,
ref: `heads/${defaultBranch}`,
sha: revertCommit.sha,
})
Creates two commits that cancel each other out. The history stays clean because the final tree is identical to the initial one. This is the default option.
Branch Strategy:
const tempBranch = `gitpins-${Date.now()}`
// Create temporary branch
await octokit.rest.git.createRef({
owner,
repo,
ref: `refs/heads/${tempBranch}`,
sha: sha,
})
// Empty commit on the temporary branch
const { data: newCommit } = await octokit.rest.git.createCommit({
owner,
repo,
message: `[GitPins] Sync position: ${position}/${total}`,
tree: commitData.tree.sha,
parents: [sha],
})
await octokit.rest.git.updateRef({
owner,
repo,
ref: `heads/${tempBranch}`,
sha: newCommit.sha,
})
// Merge to main
await octokit.rest.repos.merge({
owner,
repo,
base: defaultBranch,
head: tempBranch,
commit_message: `[GitPins] Position: ${position}/${total}`,
})
// Delete temporary branch
await octokit.rest.git.deleteRef({
owner,
repo,
ref: `heads/${tempBranch}`,
})
Creates a temporary branch, makes the commit there, merges to main, and deletes the branch. Generates a merge commit in the history.
Security and permissions
GitPins uses a GitHub App instead of traditional OAuth because Apps allow more granular permissions.
The permissions it requests are:
- Contents (read/write): To make the empty commits
- Metadata (read): To list repos
- Actions (read/write): To create the workflow
- Secrets (read/write): To configure the sync token
What GitPins cannot do:
- Read your source code (commits are empty)
- Modify existing files
- Delete repos or branches
- Access other GitHub data (issues, PRs, etc.)
Tokens are encrypted before being saved. If someone accesses the database, they see encrypted blobs, not usable tokens.
The admin panel
GitPins includes an admin panel for managing users:
- Statistics: total users, active users, banned users, total syncs
- User management: see all users with their config repos
- Ban/unban: suspend users who abuse the service
- Activity charts: registrations and syncs for the last 30 days
Access is controlled with the ADMIN_GITHUB_ID environment variable. Only the user with that GitHub ID can access /admin.
# Get your GitHub ID
curl https://api.github.com/users/YOUR_USERNAME | grep '"id"'
Self-hosting
If you prefer to host your own instance, the process is:
- Create a GitHub App on your account
- Configure the necessary permissions
- Clone the repo and configure
.env - Create a PostgreSQL database (Neon has a free tier)
- Run
npx prisma db push - Deploy to Vercel or any platform that supports Next.js
Required environment variables:
DATABASE_URL="postgresql://..."
GITHUB_APP_ID="..."
GITHUB_APP_CLIENT_ID="..."
GITHUB_APP_CLIENT_SECRET="..."
GITHUB_APP_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----..."
NEXT_PUBLIC_APP_URL="https://your-domain.com"
JWT_SECRET="..."
ENCRYPTION_SECRET="..."
ADMIN_GITHUB_ID="..."
The README has detailed instructions for creating the GitHub App with the correct permissions and configuring callback URLs.
Use cases
Developers job hunting: Your GitHub profile is your portfolio. Having your most impressive projects always visible increases the chances that a recruiter sees your best work first.
Maintainers of multiple libraries: If you maintain several open source libraries, you can prioritize the ones with the most usage or the ones you currently want to promote.
Organizing repos by context: You can group related repos by keeping them together in the order. Work projects at the top, personal projects next, experiments at the bottom.
Portfolio automation: Instead of checking your profile every week to manually reorder, GitPins does it automatically according to your configuration.
Tech stack
- Framework: Next.js 15 (App Router)
- Language: TypeScript
- Database: PostgreSQL via Prisma
- Auth: GitHub OAuth (GitHub Apps)
- Styling: Tailwind CSS
- Drag & Drop: dnd-kit
- Deployment: Vercel
The source code is at github.com/686f6c61/gitpins. The public demo is at gitpins.vercel.app.
The project is MIT licensed, so you can fork it, modify it, and host it without restrictions.